Compare commits

...

57 Commits

Author SHA1 Message Date
bryanthaboi d2a86cd258 Merge pull request #1873 from bryanthaboi/dev 2026-08-27 07:42:16 -04:00
bryanthaboi 8b56ec2d77 big egg energy 2026-08-27 07:40:02 -04:00
bryanthaboi f11724f08a Merge pull request #1841 from thibautbus/fix/gen2-pokedex-mod-text 2026-08-27 07:25:03 -04:00
bryanthaboi bd3e02986d test fix and iOS close hide 2026-08-27 07:11:43 -04:00
bryanthaboi eed246f645 CLOSES #1831, CLOSES #1834, CLOSES #1836, CLOSES #1860 2026-08-27 06:09:50 -04:00
bryanthaboi c9d06779d8 Merge pull request #1844 from colsonrice/fix/gen2-save-refusal-message
Refuse a Gen 2 cart save as a Gen 2 save, not as a broken Gen 1 one
2026-08-27 06:04:27 -04:00
bryanthaboi 48d8a4e922 CLOSES #1562, CLOSES #1804, CLOSES #1805, CLOSES #1810, CLOSES #1811, CLOSES #1814, CLOSES #1817, CLOSES #1818, CLOSES #1819, CLOSES #1821, CLOSES #1826, CLOSES #1827, CLOSES #1829, CLOSES #1833, CLOSES #1840, CLOSES #1842, CLOSES #1845, CLOSES #1846, CLOSES #1847, CLOSES #1848, CLOSES #1849, CLOSES #1853, CLOSES #1858, CLOSES #1862 2026-08-27 05:09:26 -04:00
github-actions 9a05de4474 chore(ios): update app-repo.json [skip ci] 2026-08-26 18:03:50 -04:00
bryanthaboi 5c833a3d73 Merge pull request #1864 from bryanthaboi/dev 2026-08-26 17:49:40 -04:00
bryanthaboi 5051cb902d Merge pull request #1863 from ShaneMcGovernIE/feat/handheld-osk-and-trigger-scroll 2026-08-26 17:47:04 -04:00
Shane McGovern 2720bfb5d8 feat(ui): scope on-screen VirtualKeyboard to Linux handhelds and restore L2/R2 trigger scrolling 2026-08-26 22:45:09 +01:00
github-actions 54ba0f04ff chore(ios): update app-repo.json [skip ci] 2026-08-26 17:34:24 -04:00
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
Colson Rice 3a0a72102f Teach the bug420 SaveConvert double the method importToSlot now calls
CI caught this: tests/engine/save_import_retry_bug420.lua replaces
SaveConvert with a minimal double, and importToSlot now asks it
importSupported before it measures the bytes, so the double answered nil
and the call died.

The double stands in for the real module, so it grows with it. Answering
true keeps that case about the thing it is testing, which is that
importToSlot names the game whose cache to read.

./scripts/test.sh passes end to end locally now, every ROM-free tier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:47:08 -04:00
Colson Rice fe31293566 Refuse a Gen 2 cart save as a Gen 2 save, not as a broken Gen 1 one
#1832 reports a Crystal .sav failing to import with "save data checksum
invalid (main data checksum mismatch)". The save is fine. It is being
measured with the wrong generation's ruler.

SaveFileIO.importToSlot sends anything that is not exactly SAVE_SIZE into
mainChecksumValid, which is pokered's main-data checksum. Gen 2 carts are
MBC3+TIMER, so a real Gold, Silver or Crystal battery save carries an RTC
footer: 32786 bytes, never 32768. It misses the size test, falls into the
checksum branch, and is told it is damaged by a rule written for a
different game. Every real Gen 2 cart save takes that path, every time.

The generation check already exists, it just lives too late, inside
importSav and behind the size gate that has already rejected the save.
This lifts it into SaveConvert.importSupported and exportSupported and
asks before the bytes are measured. Both existing guards route through
the same predicates now, so the early caller and the late one cannot
describe the same game two different ways.

Nothing changes for Gen 1, and Gen 2 is still refused, for the real
reason and in a sentence a player can do something with.

tests/gen2_save_import_message_test.lua covers it. Against the code as it
stands it is 20/26, and the failures are the report itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:52:44 -04:00
github-actions e41f2c528a chore(ios): update app-repo.json [skip ci] 2026-08-26 14:00:12 -04:00
thibautbus c8eeeaa1f6 Cover the #DEX registry projection fix with a targeted test 2026-08-26 19:44:00 +02:00
thibautbus 273350d11e Route the Gen2 #DEX screen's kind/text through the pokemon registry
src/ui/gen2/PokedexMenu.lua reads its KIND label and both description
pages from data.gen2Pokedex.entries, loaded straight from disk before
mods:load runs -- a separate table from data.pokemon, the `pokemon`
registry's own merge target. mod.content.pokemon:patch(id, { dexEntry =
... }) therefore validated but never reached the screen. Adds
src/core/gen2/PokedexText.lua to project a patched dexEntry onto the
#DEX table after the merge (Game2:load, alongside the other Gen 2
post-merge registries), and a text2 field to the dexEntry schema for
the entry's second description page, which the screen already reads
but the registry had no field for. Also routes the OPTION/SEARCH panel
titles (PokedexMenu.lua) through Strings(), the same literal-wrapping
pattern already used elsewhere in this screen and its siblings.
2026-08-26 19:43:53 +02: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
274 changed files with 15555 additions and 1569 deletions
+50
View File
@@ -333,6 +333,56 @@ jobs:
if-no-files-found: error
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:
name: headless suites (no ROM)
runs-on: ubuntu-latest
+174 -5
View File
@@ -143,7 +143,7 @@ jobs:
linux-arm64:
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
# AppImage compiles LÖVE natively inside a Debian bullseye arm64
# container, and the qemu-emulated alternative takes hours.
@@ -155,13 +155,22 @@ jobs:
with:
name: gen1recomp-release-love
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
env:
SHADERFX_BRIDGE_REQUIRED: "1"
run: |
set -euo pipefail
scripts/build_linux_arm64.sh \
--version "${{ needs.version.outputs.version }}" \
--game-love .bazinga/work/game.love
- name: Verify the AppImage is self-contained and bullseye-compatible
env:
SHADERFX_BRIDGE_REQUIRED: "1"
run: bash scripts/linux-arm64/verify_appimage.sh "dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage"
- name: Upload Linux arm64 release
uses: actions/upload-artifact@v7
@@ -173,6 +182,45 @@ jobs:
if-no-files-found: error
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:
name: build Xbox UWP release
needs: [version, love-payload]
@@ -286,8 +334,85 @@ jobs:
if-no-files-found: error
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:
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"') }}
steps:
@@ -309,6 +434,28 @@ jobs:
name: gen1tls-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
# every release asset ships one identical payload (build.sh's own pack
# would omit PATCH_NOTES.md and mobile/ios/app-repo.json).
@@ -358,6 +505,7 @@ jobs:
- name: Build macOS + Windows + Linux
env:
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
SHADERFX_BRIDGE_REQUIRED: "1"
run: |
set -euo pipefail
# Sign in-build (identity auto-detected from the temp keychain);
@@ -372,6 +520,8 @@ jobs:
--game-love dist/payload/game.love
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|| { 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
env:
@@ -429,6 +579,8 @@ jobs:
--version "${{ needs.version.outputs.version }}"
- name: Build Anbernic RG34XXSP port
env:
SHADERFX_BRIDGE_REQUIRED: "1"
run: |
set -euo pipefail
# 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.
GEN1RECOMP_SOURCE_DIR: ${{ github.workspace }}
GEN1RECOMP_RELEASE_TAG: v${{ needs.version.outputs.version }}
SHADERFX_BRIDGE_REQUIRED: "1"
run: |
set -euo pipefail
# Same aarch64 PortMaster-style pack for Linux ARM SBC PortMaster. The build
@@ -498,6 +651,13 @@ jobs:
name: gen1recomp-linux-arm64-release
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
if: github.repository == 'bryanthaboi/gen1recomp'
id: assets
@@ -509,16 +669,24 @@ jobs:
mkdir -p "$outdir"
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
# 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
# its own runner because LÖVE publishes no aarch64 binary and the
# 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"
[ -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"
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)"
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
@@ -656,8 +824,9 @@ jobs:
release_files=(
"dist/release/gen1recomp-${v}-macos.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.flatpak"
"dist/release/gen1recomp-${v}-android.apk"
"dist/release/gen1recomp++-${v}-ios.ipa"
"dist/release/gen1recomp-${v}-switch.zip"
+20 -11
View File
@@ -1,19 +1,19 @@
# Gen1Recomp
A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map
behavior are hand-written Lua; game data and graphics are decoded from a ROM
supplied by the player.
A native LÖVE2D recreation of Poke Red, Blue, Gold, Silver, and Crystal. The
engine and map behavior are hand-written Lua; game data and graphics are
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***
[Click Here for the AI Use Disclosure!](AIDisclosure.md)
> [!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>
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
# [SUPPORT / ANNOUNCEMENTS / MODS ALL FOUND ON THE DISCORD](https://bois.icu)
<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](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,
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 |
## Linux on arm64 (Raspberry Pi)
## Linux desktop (AppImage / Flatpak)
Alongside the x86_64 `gen1recomp-*-linux.zip`, every release ships
`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:
Releases ship raw AppImages (no zip wrapper) plus an optional Flatpak bundle:
- `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
chmod +x 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
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
+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" \
"$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.
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
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
# PortMaster-managed ports directories.
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
#!/bin/bash
# gen1recomp-sbc — Linux ARM SBC / PortMaster launcher
# Uses SHDIR-relative paths so firmware-specific mount points do not matter.
#!/usr/bin/env bash
# gen1recomp-sbc — High-Performance Native ARM64 Handheld Port Launcher
# Compatible with TrimUI Brick, TrimUI Smart Pro, Anbernic (H700/RK3566), muOS, Knulli, ArkOS, Stock OS
export HOME="${HOME:-/root}"
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
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"
CONFDIR="$GAMEDIR/conf"
mkdir -p "$CONFDIR"
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_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:-}"
# GLES is the common path on ARM SBC handhelds; firmware may override it.
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
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
# Audio buffer size
export SDL_AUDIO_SAMPLES=1024
if [ -n "${GPTOKEYB:-}" ]; then
$GPTOKEYB "love.aarch64" &
# Chip-synth sample rate. The audio worker is the dominant CPU cost while
# 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
# 4. Platform helper
if type pm_platform_helper >/dev/null 2>&1; then
echo "Running pm_platform_helper..."
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
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"
EXIT_CODE=$?
echo "Game exited with code: $EXIT_CODE"
# 6. Cleanup
restore_cpu_governors
if type pm_finish >/dev/null 2>&1; then
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
exit $EXIT_CODE
EOF
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" \
"$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.
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
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
end
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.joystick = not companion
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 },
},
},
},
}
+19 -12
View File
@@ -1251,19 +1251,26 @@ local function pokemonTower2FRivalScript(playerX)
and TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT
or TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN
return {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
{ "jump_if_true", 12 }, -- 3
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
{ "jump_if_false", "end" }, -- 6 loss: stay
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "face_player" },
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" },
{ "jump_if_true", "beaten" },
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" },
-- .DefeatedText rides BIT_PRINT_END_BATTLE_TEXT, so it prints on the
-- battle screen -- scripts/PokemonTower2F.asm:145-150
{ "save_end_battle_text", "_PokemonTower2FRivalDefeatedText" },
{ "rival_battle", "OPP_RIVAL2", 4 },
{ "jump_if_false", "end" }, -- loss: stay
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" },
-- the win re-runs DisplayTextID on the beaten branch
-- scripts/PokemonTower2F.asm:72-75, :137-140
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" },
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "walk_npc", 1, exitDirs }, -- 9
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
{ "jump", "end" }, -- 11
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 12
{ "walk_npc", 1, exitDirs },
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" },
{ "play_default_music" }, -- scripts/PokemonTower2F.asm:124
{ "jump", "end" },
{ "label", "beaten" },
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" },
}
end
+20 -21
View File
@@ -167,13 +167,11 @@ M.POKEMON_TOWER_6F = {
-- PlayCry RESTLESS_SOUL (EQU MAROWAK, constants/pokemon_constants
-- .asm:209) + WaitForSoundToFinish + DelayFrames 30 before the
-- calmed line; the port dropped the first text and the cry
-- (#867). play_cry arms the next show_text, so the cry rides
-- the calmed box's open with the button prompt kept, and the
-- wait row stands in for the asm's 30-frame gap.
-- (#867). text/PokemonTower6F.asm:1-5 (#1849)
local rows = {
{ "play_cry", "MAROWAK" },
{ "show_text", t._PokemonTower6FGhostWasCubonesMotherText
or "The GHOST was the\nrestless soul of\vCUBONE's mother!" },
{ "play_cry", "MAROWAK", true },
{ "wait", 30 },
{ "show_text", t._PokemonTower6FSoulWasCalmedText
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!" },
@@ -191,7 +189,8 @@ M.POKEMON_TOWER_6F = {
end
ow:afterBattle(result, battle)
end
game.stack:push(battle)
-- InitWildBattle runs the wipe before the disguise (core.asm:6695-6702)
ow:pushBattle(battle)
end))
return true
end,
@@ -443,6 +442,9 @@ M.ROCKET_HIDEOUT_B4F = {
or "I hope we meet\nagain..."
game.stack:push(TextBox.new(game, impressed, function()
local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1)
-- SaveEndBattleTextPointers (scripts/RocketHideoutB4F.asm:99-120):
-- PrintEndBattleText prints it on the battle screen (#1817)
battle.endBattleText = TextBox.substitute(game, cannotBe)
battle.onFinish = function(result)
if result ~= "win" then
ow:afterBattle(result, battle)
@@ -451,22 +453,19 @@ M.ROCKET_HIDEOUT_B4F = {
end
game.save.defeatedTrainers[npc.id] = true
game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true
-- End-battle "WHAT!" then BeatGiovanniScript's hope text,
-- fade, HideObject Giovanni, ShowObject Silph Scope.
game.stack:push(TextBox.new(game, cannotBe, function()
game.stack:push(TextBox.new(game, hope, function()
local Transition = require("src.render.Transition")
game.stack:push(Transition.new(game, function()
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_GIOVANNI")
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
end, function()
ow:afterBattle(result, battle)
done()
end))
-- BeatGiovanniScript (scripts/RocketHideoutB4F.asm)
game.stack:push(TextBox.new(game, hope, function()
local Transition = require("src.render.Transition")
game.stack:push(Transition.new(game, function()
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_GIOVANNI")
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
end, function()
ow:afterBattle(result, battle)
done()
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.
## 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
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
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
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
> (`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.
+36
View File
@@ -505,6 +505,42 @@ Menu choices and moves use the same engine methods as the native controls;
their mutable logic. Tutorial, link, forced, stale, and covered battle states
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
+1 -1
View File
@@ -821,7 +821,7 @@ mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY", { map = "ROUTE_31" })
| `catchRate` | integer 0..255 | yes |
| `cry` | cries id | no |
| `dex` | integer >= 1 | yes |
| `dexEntry` | {heightFt, heightIn, heightM?, kind, text, weight, weightKg?} | no |
| `dexEntry` | {heightFt, heightIn, heightM?, kind, text, text2?, weight, weightKg?} | no |
| `evolutions` | list of {item?, level?, method, species} | yes |
| `frontSize` | integer 1..7 | yes |
| `growthRate` | growth_rates id | yes |
+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.
+42 -3
View File
@@ -214,12 +214,51 @@ finally the bare name handed to the system loader. Per-OS names are
`librashader_bridge.dylib` (macOS), and `liblibrashader_bridge.so` or
`librashader_bridge.so` (Linux and Android). Android resolves the bare name
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
output directory; nothing packages it next to a shipped game yet.
`ShaderFX.canConvert()` reports whether the library resolved on this machine,
without a path. `ShaderFX.canConvert()` reports whether the library resolved on this machine,
and `ShaderFX.bridgeError()` says why not. Activating an already-converted
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
+3 -2
View File
@@ -63,8 +63,9 @@ mounted or deleted as stale; the launcher directs the player to a full package.
## Release assets
Each tagged release `vX.Y.Z` carries the existing per-platform archives
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
`-linux-arm64.AppImage`, `-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`,
`-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:
- `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
+95 -9
View File
@@ -22,6 +22,56 @@ local PlatformHooks = require("src.core.PlatformHooks")
local HostDisplay = require("src.core.HostDisplay")
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.
do
local defaultErrorHandler = love.errorhandler or love.errhand
@@ -30,10 +80,33 @@ do
if ok and hint and type(msg) == "string" then
msg = msg .. "\n\n" .. hint
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
return defaultErrorHandler(msg)
end
end
love.errhand = love.errorhandler
end
local Game, EditorApp, Importer, TouchEditor, Studio
@@ -384,12 +457,14 @@ function bootGame(version, cartId)
Game = require("src.core.Game2").new()
Game:load()
else
-- Gen1 Game is a module singleton. In-process EXIT GAME resets it in
-- place; if a prior teardown left load missing, rebuild from source.
-- 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(gameMod.load) ~= "function" then
package.loaded["src.core.Game"] = nil
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()
@@ -557,6 +632,7 @@ function love.load(args)
end
function love.update(dt)
checkEmergencyQuit(dt)
HostDisplay.update(dt)
SwitchDiagnostics.maybeFlush(false)
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
@@ -1125,15 +1201,23 @@ function love.quit()
-- 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
-- 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()
return Game and not Importer and not quitToLauncher and not scripted
and (isAndroid or not launchedIntoGame)
and (inProcessReturn or not launchedIntoGame)
end)
if wouldReturnToLauncher then
if isAndroid then
if inProcessReturn then
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
quitToLauncher = true
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
@@ -1219,6 +1303,8 @@ function love.run()
if love.timer then dt = love.timer.step() end
idleFor = idleFor + dt
checkEmergencyQuit(dt)
-- call update and draw
if love.update then love.update(dt) end
@@ -182,7 +182,7 @@ void System::vibrate(double seconds) const
#endif
}
bool System::pickFile(const char *kind) const
bool System::pickFile(const char *kind, const char *destination) const
{
#ifdef LOVE_ANDROID
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)
dest = "picked_save.sav";
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)
dest = "picked_rom.gb";
// Unknown kinds used to fall through to the ROM destination. Refuse them
@@ -116,7 +116,7 @@ public:
* "required_import" -> picked_required_import.bin.
* @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;
/**
@@ -101,7 +101,8 @@ int w_vibrate(lua_State *L)
int w_pickFile(lua_State *L)
{
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;
}
@@ -39,6 +39,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.security.MessageDigest;
import android.Manifest;
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
// instead of leaving the player on "No ROM imported" (issue #442).
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
// consumed by the Pokéwalker mod, same contract as the iOS
// 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_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. */
@Keep
public static boolean showFilePicker(String destFilename) {
@@ -593,14 +611,30 @@ public class GameActivity extends SDLActivity {
// onActivityResult copies the pick there, not into a recomputed
// (possibly different-volume) root (#604, #839).
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
// Reject path separators so a hostile JNI caller cannot escape the
// save identity directory.
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
// Basename destinations keep the historical ROM/mod/save staging path.
// A nested destination is accepted only for an engine-generated mod
// baseroms path, then canonicalized beneath LOVE's mounted save root.
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;
}
self.pendingPickFilename = destFilename;
self.pendingPickFilename = normalizedDest;
if (android.os.Build.VERSION.SDK_INT >= 21) {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
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. */
private void writeSaveDirFlag(String name, String body) {
try {
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());
}
writeFlagFile(saveIdentityDir(), name, body);
}
/**
@@ -1506,9 +1534,26 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "could not create " + destDir);
return;
}
String destName = pendingPickFilename != null
final String destName = pendingPickFilename != null
? 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
// 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);
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);
// A truncated pick would only fail verification later, so drop it
// and report instead.
destFile.delete();
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.
*
@@ -1565,13 +1705,12 @@ public class GameActivity extends SDLActivity {
assert (source != null && destination != null);
try {
byte[] buf = new byte[1024];
chunk_read = source.read(buf);
do {
byte[] buf = new byte[1024 * 1024];
while ((chunk_read = source.read(buf)) != -1) {
if (chunk_read == 0) continue;
destination.write(buf, 0, chunk_read);
bytes_written += chunk_read;
chunk_read = source.read(buf);
} while (chunk_read != -1);
}
} catch (IOException e) {
Log.d("GameActivity", "Copying failed:" + e.getMessage());
}
+35
View File
@@ -12,6 +12,41 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.2.29",
"date": "2026-08-26",
"size": 21366635,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.29/gen1recomp++-0.2.29-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @ShaneMcGovernIE"
},
{
"version": "0.2.28",
"date": "2026-08-26",
"size": 21366087,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.28/gen1recomp++-0.2.28-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"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",
+101 -4
View File
@@ -13,6 +13,7 @@
import UIKit
import UniformTypeIdentifiers
import CryptoKit
@objc(GRPickerBridge)
public final class GRPickerBridge: NSObject {
@@ -190,9 +191,17 @@ public final class GRPickerBridge: NSObject {
@objc(presentPickerWithKind:saveDir:)
public static func presentPicker(kind: UnsafePointer<CChar>?,
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"
guard let dir = resolvedSaveDir(saveDir) else { return false }
let requestedDestination = destination.map { String(cString: $0) }
let destName: String
var types: [UTType] = []
switch kindStr {
@@ -202,7 +211,13 @@ public final class GRPickerBridge: NSObject {
case "sav":
destName = "picked_save.sav"
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 --
// 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
@@ -234,12 +249,18 @@ public final class GRPickerBridge: NSObject {
types.append(.data)
if !types.contains(.item) { types.append(.item) }
let directRequired = kindStr == "required_import"
&& destName != "picked_required_import.bin"
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types,
asCopy: true)
asCopy: !directRequired)
picker.allowsMultipleSelection = false
let delegate = PickerDelegate { urls in
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)
}
@@ -410,6 +431,82 @@ public final class GRPickerBridge: NSObject {
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) {
let scoped = src.startAccessingSecurityScopedResource()
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
@@ -426,7 +523,7 @@ public final class GRPickerBridge: NSObject {
let report = "Could not copy \(src.lastPathComponent): " +
error.localizedDescription
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)
{
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
+72 -26
View File
@@ -11,7 +11,7 @@
#
# Output: dist/mac/gen1recomp-macos.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
# mobile/android/app/build/outputs/apk/embedNoRecord/)
# dist/ios/<Config>-<sdk>/gen1recomp++.app (full xcodebuild output stays
@@ -170,27 +170,53 @@ make_ico() { # $1 = output .ico path
# --------------------------------------------------------------- macOS
# ShaderFX's librashader bridge. Only the CONVERT action needs it, so a build
# 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() {
local dest="$1" name="$2"
local src="${SHADERFX_BRIDGE:-}"
local dest="$1" name="$2" plat="$3"
local crate="$ROOT/tools/shaderfx-bridge"
if [ -z "$src" ] && [ -f "$crate/target/release/$name" ]; then
src="$crate/target/release/$name"
local src="" var
[ -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
if [ -z "$src" ] && 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
if [ -z "$src" ] && [ -n "${SHADERFX_BRIDGE:-}" ]; then
src="$SHADERFX_BRIDGE"
fi
if [ -z "$src" ] && [ "$plat" = "$(shader_bridge_host_plat)" ]; then
if [ -f "$crate/target/release/$name" ]; then
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
if [ -n "$src" ] && [ -f "$src" ]; then
mkdir -p "$dest"
cp "$src" "$dest/$name"
say "bundled $name for SHADER FX preset conversion"
else
warn "$name not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE or install cargo)"
say "bundled $name for SHADER FX preset conversion ($plat)"
return 0
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() {
@@ -205,7 +231,7 @@ build_mac() {
# drop any bundled placeholder .love and fuse ours in
find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete
cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love"
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"
/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)"
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
# 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
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
# 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.
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
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"
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" \
|| 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\
exec "$APPDIR/bin/love"|' "$appdir/AppRun"
local wayland_hook='if [ -n "$WAYLAND_DISPLAY" ] && [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\
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
# bundled runtime can read it.
@@ -474,16 +516,20 @@ exec "$APPDIR/bin/love"|' "$appdir/AppRun"
mksquashfs "$appdir" "$sfs_out" \
-comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null
local out_bin="$WORK/$APP_NAME-x86_64.AppImage"
rm -f "$out_bin"
mkdir -p "$DIST/linux"
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"
cat "$sfs_out" >> "$out_bin"
chmod +x "$out_bin"
local zip_out="$DIST/linux/$APP_NAME-linux.zip"
rm -f "$zip_out"
(cd "$WORK" && zip -q -9 -j "$zip_out" "$(basename "$out_bin")")
say "Linux build: $zip_out"
if command -v sha256sum >/dev/null 2>&1; then
printf '%s %s\n' "$(sha256sum "$out_bin" | awk '{print $1}')" "$(basename "$out_bin")" \
> "$out_bin.sha256"
else
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
+7
View File
@@ -106,6 +106,13 @@ if [ -n "$TEST_APPLICATION_ID" ]; then
APPLICATION_ID="$TEST_APPLICATION_ID"
APP_NAME="$APP_NAME (test)"
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
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
+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")"
+15
View File
@@ -100,6 +100,21 @@ then
fi
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
# Fetched on the host and checksum-pinned here so the container never needs
# 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' ' ')"
# ------------------------------------------------- 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
# README: these are the ONLY sonames the shipped objects may require from the
# 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)$'
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
[ -n "$soname" ] || continue
# 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; }
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
# 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
# started on a full desktop -- a bare runner is what exposed it.
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
ldd "${scan[@]}" 2>/dev/null \
| grep 'not found' || true)"
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
# Nothing may hard-link a driver, session or audio-stack library:
# those must be reached through dlopen so the AppImage runs on a box
# with only ALSA, only Wayland, or only KMSDRM.
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
linked="$(for f in "${scan[@]}"; do
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
[ -z "$linked" ] \
@@ -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
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
# it here instead of in a release.
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
floor="$(objdump -T "${scan[@]}" 2>/dev/null \
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
echo "highest required glibc symbol version: $floor"
[ -n "$floor" ] \
@@ -54,4 +71,12 @@ highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
[ "$highest" = "GLIBC_2.31" ] \
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
# 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"
+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 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 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 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
# 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
+11 -9
View File
@@ -104,7 +104,9 @@ local SE_FRAMES = {
SE_FLASH_MON_PIC = 4, SE_FLASH_ENEMY_MON_PIC = 4,
SE_TRANSFORM_MON = 4,
SE_SUBSTITUTE_MON = 3,
SE_WAVY_SCREEN = 255, -- AnimationWavyScreen: ld c, $ff frames
-- AnimationWavyScreen: `ld c, $ff` counts outer passes, and the inner
-- loop exits twice per displayed frame (animations.asm:1884-1903)
SE_WAVY_SCREEN = 128,
}
-- data/battle_anims/special_effects.asm AnimationIdSpecialEffects:
@@ -502,15 +504,10 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
local transform = resolveTransform(sub.type, attackerIsPlayer)
local first, last, dir = 1, #sub.blocks, 1
if transform == "REVERSE" then first, last, dir = last, first, -1 end
-- DoBallShakeSpecialEffects: each ball shake opens with SFX_TINK
-- and a 40-frame pause, then rewinds the same subanimation; the
-- mode-4 frame blocks persist, so the resting ball stays visible
-- through the pauses between wobbles
-- DoBallShakeSpecialEffects: animations.asm:739-747, :623-627
local pendingTink = false
for _ = 1, (opts and opts.shakes) or 1 do
if opts and opts.shakes then
events[#events + 1] = { effect = "SFX_TINK", frame = frame }
emit(40)
end
pendingTink = opts and opts.shakes ~= nil
local dest = 1 -- PlaySubanimation resets the OAM cursor per row
local nblocks = math.abs(last - first) + 1
local played = 0
@@ -568,6 +565,11 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
-- DoSpecialEffectByAnimationId runs after every frame
-- block with wSubAnimCounter = blocks remaining
played = played + 1
if pendingTink then
pendingTink = false
events[#events + 1] = { effect = "SFX_TINK", frame = frame }
emit(40)
end
if ballFlicker then obp0Flip = not obp0Flip end
if idFx then
local counter = nblocks - played + 1
+151 -39
View File
@@ -34,6 +34,12 @@ local RomText = require("src.core.RomText")
local Strings = require("src.core.Strings")
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 BattleState = {}
@@ -334,6 +340,22 @@ local function monPalette(data, species)
return { name = name, colors = colors }
end
-- MarowakAnim OBJ pics under OAM_PAL1:
-- engine/battle/ghost_marowak_anim.asm:3-5,77
local function objPicPalette()
local PaletteFX = require("src.render.PaletteFX")
if not PaletteFX.usesSpriteObp() then return nil end
local colors, group = PaletteFX.ogObj()
if not colors then return nil end
return { name = "obp1:" .. tostring(group), colors = colors }
end
local function objPic(path, trueColor)
local pal = objPicPalette()
if not pal then return nil end
return getImage(path, pal, trueColor)
end
-- a named palette from the active COLORS pack as a getImage pal
local function namedPalette(data, name)
local PaletteFX = require("src.render.PaletteFX")
@@ -461,7 +483,7 @@ function BattleState:picImage(img)
-- palettes (CYANMON reds 0.678/0.451) already rendered correctly. This mode
-- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync.
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
-- 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
@@ -891,7 +913,8 @@ local function disguiseAsGhost(self)
self.enemy.name = "GHOST"
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
monPalette(self.data, self.enemy.mon.species))
self.introText = Strings("The GHOST\nappeared!")
-- _EnemyAppearedText (data/text/text_2.asm:1251-1255) has no article
self.introText = self:romText("_EnemyAppearedText", "%s\nappeared!", self.enemy.name)
end
-- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope
@@ -935,7 +958,11 @@ function BattleState:queueScopeReveal()
local unveiled = self.data.text and self.data.text._UnveiledGhostText
self:say(unveiled
or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!"))
self:act(function() self.ghostReveal = { t = 0 } end)
self:act(function()
self.ghostReveal = { t = 0 }
local ghostObj = objPic("assets/generated/battle/front/ghost.png")
if ghostObj then self.enemy.sprite = ghostObj end
end)
table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES })
self:say(self:romText("_WildMonAppearedText", "Wild %s\nappeared!",
self.ghostReal and self.ghostReal.name or self.enemy.name))
@@ -3258,9 +3285,9 @@ function BattleState:applyAnimEffect(ev)
end
fx.hudShakeProg = prog
elseif e == "SE_WAVY_SCREEN" then
-- AnimationWavyScreen: 255 frames of per-scanline SCX offsets
-- walking WavyScreenLineOffsets
fx.wavy = { left = 255, phase = 0 }
-- AnimationWavyScreen: 255 outer passes, two per displayed frame
-- (animations.asm:1884-1903), walking WavyScreenLineOffsets
fx.wavy = { left = 128, phase = 0 }
-- ---------------------------------------------- mon pic effects
elseif e == "SE_SLIDE_MON_OFF" then
@@ -3644,7 +3671,7 @@ function BattleState:updateFx()
end
if fx.wavy then
fx.wavy.left = fx.wavy.left - 1
fx.wavy.phase = fx.wavy.phase + 1
fx.wavy.phase = fx.wavy.phase + 2
if fx.wavy.left <= 0 then fx.wavy = nil end
end
end
@@ -3698,12 +3725,22 @@ function BattleState:updateFx()
local real = self.ghostReal
if real then
self.enemy.name = real.name or self.enemy.name
self.enemy.sprite = real.sprite or self.enemy.sprite
gr.bgSprite = real.sprite or self.enemy.sprite
local objReal
if objPicPalette() then
local Sprites = require("src.pokemon.Sprites")
local path, tc = Sprites.path(self.data, self.enemy.mon.species,
"front", { mon = self.enemy.mon, kind = "battle" })
objReal = path and objPic(path, tc)
end
self.enemy.sprite = objReal or gr.bgSprite
end
end
pf.fade = math.min(1, math.ceil((gr.t - outEnd) / 10) / 4)
end
if gr.t >= BattleState.GHOST_REVEAL_FRAMES then
-- home/clear_sprites.asm:1
if gr.bgSprite then self.enemy.sprite = gr.bgSprite end
self.ghostReveal, self.scopeReveal, pf.fade = nil, nil, nil
end
end
@@ -3819,6 +3856,9 @@ function BattleState:executeAction(user, target, action)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides()
-- EnemySendOut (core.asm:1276-1289): only the mon on the field stays flagged
self.participants = {}
self:markParticipant()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy,
previous = previous,
@@ -4531,8 +4571,7 @@ function BattleState:enemyMonFainted()
-- remaining HP); SET / single-mon / fainted active skip the prompt.
local nextMon = self.enemyParty[self.enemyIndex]
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
:lower()
local style = self:battleStyle()
local partyCount = #self:playerPartyView()
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
@@ -5100,6 +5139,57 @@ function BattleState:ballMissMessage(shakes)
return t._ItemUseBallText04 or self:romText("_ItemUseBallText04", "Shoot! It was so\nclose too!")
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,
-- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party
-- AddPartyMon and SendNewMonToBox (#172).
@@ -5180,11 +5270,7 @@ function BattleState:storeCaughtMon()
end)
end
local function askCaughtNickname()
local caught = self.enemy.mon
local enemyName = self.enemy.name
self:uiNext(function()
return self:askNicknameUI(caught, enemyName)
end)
self:offerNickname(self.enemy.mon, self.enemy.name)
end
if Party.add(game.save.party, self.enemy.mon) then
askCaughtNickname()
@@ -5898,8 +5984,7 @@ local WAVY_OFFSETS = { 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1, 1,
0, 0, 0, 0, 0, -1, -1, -1, -2, -2, -2, -2, -2,
-1, -1, -1 }
-- wave the BG canvas one scanline at a time; the offset table walks
-- one entry per frame like the asm's advancing pointer
-- WavyScreen_SetSCX: animations.asm:1916-1927
function BattleState:applyWavy(src)
local wavy = self.fx and self.fx.wavy
if not wavy then return src end
@@ -5912,7 +5997,7 @@ function BattleState:applyWavy(src)
for line = 0, 143 do
self.waveQuad:setViewport(0, line, 160, 1)
g.draw(src, self.waveQuad,
WAVY_OFFSETS[(line + wavy.phase) % 32 + 1], line)
WAVY_OFFSETS[(line * 2 + wavy.phase) % 32 + 1], line)
end
g.setCanvas(prev)
return self.waveCanvas
@@ -5943,7 +6028,7 @@ function BattleState:drawZonePass(src, sx, sy)
-- only the other two showed it. Keep this mode set in sync with picImage /
-- PaletteFX.ensureZones / WideBattle.monoMode.
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.setShader(shader)
local shaking = sx ~= 0 or sy ~= 0
@@ -6353,8 +6438,35 @@ end
function BattleState:drawTextArea()
if not self:bottomUIVisible() then return end
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
-- Gold only: routes the command box and its labels through GbcPalette,
-- 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"
and (self.current or self.animPlaying or self.msgHold) then
-- during the move animation self.current is nil but shown still holds
@@ -6373,48 +6485,47 @@ function BattleState:drawTextArea()
for li, line in ipairs(self.shown or {}) do
local y = (ys[li] or 128) + off
for i = 1, #line do
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
drawGlyph(line[i], 8 + (i - 1) * 8, y)
end
end
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait
-- (_ContText) or a typed-out page (PromptText) holds the box; both write
-- it at (18,16), bottom-right, like TextBox / home/text.asm (#317)
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
elseif self.phase == "menu" and self.demo then
-- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the
-- standard menu, with the '▶' hand drawn by the scripted keystrokes
-- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16)
Font.drawBox(8, 12, 12, 6)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("FIGHT", "battle"), 80, 112)
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128)
Font.drawCode(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128)
box(8, 12, 12, 6)
text(Strings("FIGHT", "battle"), 80, 112)
drawGlyph(0xE1, 128, 112); drawGlyph(0xE2, 136, 112)
text(Strings("ITEM", "battle"), 80, 128); text(Strings("RUN", "battle"), 128, 128)
drawGlyph(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128)
elseif self.phase == "menu" then
local col = (self.menuIndex - 1) % 2
local row = math.floor((self.menuIndex - 1) / 2)
if self.safari then
-- SAFARI_BATTLE_MENU_TEMPLATE: full-width box, "BALLx BAIT /
-- THROW ROCK RUN" from (2,14)
Font.drawBox(0, 12, 20, 6)
Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112)
Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN", "battle"), 112, 128)
box(0, 12, 20, 6)
text(Strings("BALLx"), 16, 112); text(Strings("BAIT"), 112, 112)
text(Strings("THROW ROCK"), 16, 128); text(Strings("RUN", "battle"), 112, 128)
-- DisplayBattleMenu .safariLeftColumn / .safariRightColumn print
-- wNumSafariBalls at hlcoord 7,14 with `lb bc, 1, 2` -- one byte, two
-- digits, space padded -- right after the "BALLx" label at columns
-- 2..6 (engine/battle/core.asm:2074-2079, 2107-2112) (#540)
Font.draw(("%2d"):format(self.safari.balls), 56, 112)
Font.drawCode(0xED, (col == 0 and 8 or 104), 112 + row * 16)
text(("%2d"):format(self.safari.balls), 56, 112)
drawGlyph(0xED, (col == 0 and 8 or 104), 112 + row * 16)
else
-- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT <PK><MN> /
-- ITEM RUN" from (10,14); cursor columns 9 / 15
Font.drawBox(8, 12, 12, 6)
Font.draw(Strings("FIGHT", "battle"), 80, 112)
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128)
Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16)
box(8, 12, 12, 6)
text(Strings("FIGHT", "battle"), 80, 112)
drawGlyph(0xE1, 128, 112); drawGlyph(0xE2, 136, 112)
text(Strings("ITEM", "battle"), 80, 128); text(Strings("RUN", "battle"), 128, 128)
drawGlyph(0xED, (col == 0 and 72 or 120), 112 + row * 16)
end
elseif self.phase == "moveSelect" then
-- pokered MoveSelectionMenu: move list in a box at (4,12) 16x6,
@@ -6489,6 +6600,7 @@ function BattleState:drawTextArea()
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
end
if finishGlyph then finishGlyph() end
end
function BattleState:draw()
+1 -5
View File
@@ -26,14 +26,10 @@ local WideBattle = {
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 m = PaletteFX.mode
return m == "og" or m == "og_inv" or m == "classic"
or PaletteFX.forcesRawGrays()
end
local function shownHP(battler)
+1
View File
@@ -88,6 +88,7 @@ function AnimRunner.new(opts)
}
self.objects = AnimObjects.new(self.data, self.constants, self.env)
self.bg = BgEffects.new(self.constants, self.env)
self.animId = opts.animId -- wFXAnimID
self.gfxOrder = self.constants.battleAnimGfxOrder or {}
self.sfxOrder = opts.sfxOrder or {}
+23
View File
@@ -170,6 +170,10 @@ function Data:seedDefaults(version)
-- 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).
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)
require("src.world.SsAnneLayout").apply(self.maps)
end
@@ -201,6 +205,25 @@ function Data:seedFightingDojoKarateMaster()
}
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()
local headers = self.trainer_headers
if not headers or headers.CinnabarGym then return end
+9 -2
View File
@@ -186,7 +186,8 @@ function Game:makeTitleState()
onContinue = function()
local loaded, recovered = SaveData.load()
if loaded then
self:restoreSave(loaded, recovered, { freshBoot = true })
self:restoreSave(loaded, recovered,
{ freshBoot = true, continued = true })
end
end,
onExit = self.onExit,
@@ -1258,6 +1259,7 @@ function Game:applyOptions(opts)
if Sound.applyOptions then Sound.applyOptions(opts) end
require("src.render.PaletteFX").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
-- just restored back off (the two are mutually exclusive)
require("src.render.Pipelines").applyOptions(opts)
@@ -1321,6 +1323,9 @@ function Game:restoreSave(loaded, recovered, opts)
report.recovered = recovered
report.modsDiff = modsDiff
self.save = loaded
-- the START cursor lives outside sGameData (ram/sram.asm:17-21)
loaded.startMenuIndex = nil
self.startMenuIndex = nil
self:adoptSave(loaded)
-- SaveData.load already attached the standalone options.lua table
self:applyOptions(loaded.options)
@@ -1335,8 +1340,10 @@ function Game:restoreSave(loaded, recovered, opts)
-- freshBoot threads through from the caller (onContinue and F2 both set
-- it); a future caller that doesn't ask for it keeps the ordinary
-- crossfade by default.
-- engine/menus/main_menu.asm:110 (CONTINUE forces PLAYER_DIR_DOWN)
local facing = (opts and opts.continued) and "down" or loaded.player.facing
self.stack:push(self.overworld, loaded.player.map,
loaded.player.x, loaded.player.y, loaded.player.facing,
loaded.player.x, loaded.player.y, facing,
{ via = "boot", freshBoot = opts and opts.freshBoot })
self.saveReport = report
if not SaveData.emptyReport(report) then
+7
View File
@@ -1024,6 +1024,11 @@ function Game2:load()
require("src.core.gen2.Phone").useRegistry(self.data)
require("src.core.gen2.Decorations").useRegistry(self.data)
require("src.core.gen2.Apricorns").useRegistry(self.data)
-- data.gen2Pokedex is a separate table from the `pokemon` registry's own
-- merge target (data.pokemon): a translation mod's
-- mod.content.pokemon:patch(id, { dexEntry = ... }) would otherwise never
-- reach the #DEX screen. See src/core/gen2/PokedexText.lua.
require("src.core.gen2.PokedexText").apply(self.data)
-- Rendering pipelines: the engine half of the render_pipelines registry
-- (src/render/Pipelines.lua). install() points it at GOLD's merged dataset
@@ -1787,6 +1792,7 @@ function Game2:hotkey(key)
local GbcPalette = require("src.render.GbcPalette")
GbcPalette.setMode(options.color or "gbc")
options.color = GbcPalette.cycle(1)
options.palette = ""
persist()
return true
elseif key == "3" then
@@ -2040,6 +2046,7 @@ function Game2:applyOptions()
Zoom.allowSurvey = caps.survey
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
require("src.render.Tilt").applyOptions(options)
require("src.render.Letterbox").applyOptions(options)
require("src.render.GbcPalette").applyOptions(options)
-- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame.
Font.setFrame(options.frame or 1)
+21 -2
View File
@@ -40,8 +40,10 @@ GamepadMap.NX_RAW_BUTTON_BINDINGS = {
-- Raw index -> gamepad button *name* for RomImporter (then NX face swap applies).
GamepadMap.RAW_TO_GAMEPAD_BUTTON = {
[1] = "a", [2] = "b",
[1] = "a", [2] = "b", [3] = "x", [4] = "y",
[5] = "leftshoulder", [6] = "rightshoulder",
[7] = "back", [8] = "start", [9] = "back", [10] = "start",
[11] = "triggerleft", [12] = "triggerright",
}
GamepadMap.NX_RAW_TO_GAMEPAD_BUTTON = {
@@ -57,7 +59,10 @@ function GamepadMap._setForceNXForTests(v)
end
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.system and love.system.getOS() == "NX" then return true end
return false
@@ -78,6 +83,20 @@ function GamepadMap.mapGamepadButton(button)
return GamepadMap.gamepadBindings()[button]
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+A → "2" (COLORS), Select+B → "3" (TILT),
-- 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
-- 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
-- their proper libraries. Only needed when running in an AppImage.
-- LD_PRELOAD is Steam's overlay (#1470): its 32-bit half cannot load into a
-- 64-bit child, so ld.so prints an error into that child's output.
-- system ones. Host tools (curl, zenity) need that unset. A *bundled* AppDir
-- curl must keep APPDIR on LD_LIBRARY_PATH so it resolves the bundled
-- libssl/libcrypto -- scrubbing it forces host OpenSSL and segfaults across
-- distros. LD_PRELOAD is always scrubbed: Steam's overlay (#1470) cannot load
-- into a 64-bit child.
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 = ""
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 unset == "" then return "" end
return "env " .. unset
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
-- 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
@@ -126,12 +187,19 @@ local function withPopenLock(fn)
if not okAtomic then fn() end
end
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
function HostShell.popen(command, mode)
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed.
-- 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()
local prefix = HostShell.envPrefix()
if type(opts) == "table" and opts.envPrefix ~= nil then
prefix = opts.envPrefix
end
local pipe
local line = HostShell.shellCommand(prefix .. command)
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
end)
return pipe
@@ -158,7 +226,15 @@ end
-- and the app dies. There we relaunch through the GameActivity.restartApp
-- JNI bridge (love.system.restartApp), which schedules our launch intent
-- 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()
if not (love and love.event and love.event.quit) then return end
@@ -174,9 +250,23 @@ function HostShell.restart()
love.event.quit()
return
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")
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")
return
end
@@ -274,6 +364,18 @@ local function fetchError(url, status, noise)
return ("fetch failed for %s: %s"):format(url, why)
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.
function HostShell.quote(s)
s = tostring(s)
@@ -325,7 +427,10 @@ local curlAvailable = nil
function HostShell.haveCurl()
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
local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe)
@@ -333,6 +438,12 @@ function HostShell.haveCurl()
return curlAvailable
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"
-- error the callers already show.
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
userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then
local cmd = ("curl -fsSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 15 --max-time %d ")
:format(tonumber(maxTime) or 300)
local head, popts = curlCmd(
("-fsSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 15 --max-time %d ")
:format(tonumber(maxTime) or 300))
local cmd = head
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
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) .. " "
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. 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
local readOk, out = pcall(function() return pipe:read("*a") end)
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
-- 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.
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40)
local head, popts = curlCmd(
("-sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40))
local cmd = head
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
end
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. 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
local readOk, out = pcall(function() return pipe:read("*a") end)
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
-- for both writing and reading. No -f, matching httpGet: the response
-- body is discarded anyway, and curl's stderr carries the diagnosis.
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40)
local head, popts = curlCmd(
("-sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40))
local cmd = head
.. "-X POST "
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if contentType then
@@ -558,7 +675,7 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
.. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " "
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd)
local pipe = HostShell.popen(cmd, "r", popts)
if not pipe then
pcall(os.remove, bodyPath)
return nil, "could not run curl"
@@ -735,8 +852,10 @@ function HostShell.httpRequest(url, opts)
pcall(os.remove, headerPath)
end
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d "):format(maxTime)
local head, popts = curlCmd(
("-sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d "):format(maxTime))
local cmd = head
.. "-X " .. HostShell.quote(method) .. " "
.. "-H " .. HostShell.quote("@" .. headerPath) .. " "
if body then
@@ -745,7 +864,7 @@ function HostShell.httpRequest(url, opts)
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd)
local pipe = HostShell.popen(cmd, "r", popts)
if not pipe then
cleanup()
return nil, "could not run curl"
+5 -1
View File
@@ -227,7 +227,11 @@ local function metadata(options, context)
end
if flags and flags.fullscreen == true then add("Fullscreen", "yes") end
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())
if safeMode then add("Safe mode", "on") end
return {
+37 -2
View File
@@ -173,13 +173,40 @@ function SaveData.gameFolders()
end
-- 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()
if portableChecked then return portableBase end
portableChecked = true
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
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
break
end
@@ -187,6 +214,13 @@ local function detectPortable()
return portableBase
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()
return detectPortable() ~= false
end
@@ -279,6 +313,7 @@ function SaveData.defaultOptions()
zoom = 0,
-- OVERWORLD beyond-edge fill: trees | water | black
voidFill = "trees",
uiLetterbox = "auto",
-- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed",
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
+15 -6
View File
@@ -30,6 +30,8 @@ function SessionLifecycle.endMountedSession(version)
local Runtime = require("src.mods.Runtime")
if Runtime.reset then Runtime.reset() 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")
if okCompat and LegacyCompat.reset then LegacyCompat.reset() end
end
@@ -64,11 +66,18 @@ end
-- EXIT GAME / intent_game before dropping Game. Stops audio and resets the
-- 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)
pcall(function() require("src.core.Music").stop() end)
pcall(function() require("src.core.Sound").stop() end)
if package.loaded["src.core.ChipAudio"] then
pcall(package.loaded["src.core.ChipAudio"].shutdown)
pcall(package.loaded["src.core.ChipAudio"].stopMusic)
end
if package.loaded["src.core.DiscordPresence"] then
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
@@ -87,11 +96,11 @@ function SessionLifecycle.endGameSession(game)
if game and game.reset then
pcall(function() game:reset() end)
end
-- Gen1 Game is the module singleton. If teardown left it without load,
-- drop the cached module so the next require rebuilds a clean table
-- (bootGame also guards this; doing it here keeps Play-again reliable).
if game and type(game.load) ~= "function"
and package.loaded["src.core.Game"] == game then
-- 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
+15 -3
View File
@@ -22,10 +22,22 @@ local Version = {
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)
return (base or "gen1recomp")
.. " v" .. Version.engine
base = base or "gen1recomp"
if Version.isDev() then
return base .. " v" .. Version.engine
end
return base
end
return Version
+4 -2
View File
@@ -1101,8 +1101,10 @@ function Breeding.hatch(data, save, index, nickname, where)
hatched.experience = egg.experience
-- `ld a, [de] / ld [hli], a` twice: HP := MaxHP.
hatched.hp = hatched.maxHp
hatched.ot = egg.ot or (save.player and save.player.name)
hatched.otId = egg.otId or (save.player and save.player.id)
-- HatchEggs overwrites OT unconditionally, so the ODD_EGG's "ODD" and its
-- table id do not survive -- engine/pokemon/breeding.asm:299-309
hatched.ot = (save.player and save.player.name) or egg.ot
hatched.otId = (save.player and save.player.id) or egg.otId
hatched.caughtLevel = egg.level or Breeding.EGG_LEVEL
-- SetEggMonCaughtData swaps wCurPartyLevel for CAUGHT_EGG_LEVEL around the
-- shared setter -- engine/pokemon/caught_data.asm:235-246.
+39
View File
@@ -0,0 +1,39 @@
-- Projects a mod's `pokemon` registry dexEntry onto the #DEX screen's own
-- data table.
--
-- data.gen2Pokedex.entries[species] (data/generated/pokedex.lua,
-- RomExtractorGen2:extractPokedex) is what src/ui/gen2/PokedexMenu.lua
-- actually reads for the KIND label and the two description pages
-- (entry.kind, entry.text, entry.text2) -- a table loaded straight from
-- disk in Game2:load, BEFORE mods:load runs, and never routed through a
-- registry. The `pokemon` registry's own merge target is the separate
-- data.pokemon table (src/mods/Schemas.lua, R.pokemon: `target = "pokemon"`),
-- which mod.content.pokemon:patch(id, { dexEntry = { kind = ..., text = ...,
-- text2 = ... } }) already reaches -- so a translation mod's #DEX text was
-- silently invisible in-game despite validating against the registry.
--
-- This is the missing link: called once after the merge (src/core/Game2.lua),
-- the same way ItemEffects.applyHeldItems projects held_items onto
-- data.gen2HeldItems. height/weight/dex stay untouched -- game state, not
-- text a translation carries.
local PokedexText = {}
function PokedexText.apply(data)
local dex = data and data.gen2Pokedex
local pokemon = data and data.pokemon
if not (dex and dex.entries and pokemon) then return 0 end
local count = 0
for species, entry in pairs(dex.entries) do
local def = pokemon[species]
local override = def and def.dexEntry
if override and (override.kind or override.text or override.text2) then
if override.kind then entry.kind = override.kind end
if override.text then entry.text = override.text end
if override.text2 then entry.text2 = override.text2 end
count = count + 1
end
end
return count
end
return PokedexText
+2
View File
@@ -309,6 +309,7 @@ Save.DEFAULT_OPTIONS = {
-- Game Boy. The Gen 1 save's equivalent key is `colors` (SGB packs), which
-- means something different, hence the different name.
color = "gbc",
palette = "",
videoMode = "windowed",
fpsCap = 60,
-- 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
-- border block with the dissolve across a boundary (#1418).
voidFill = "fade",
uiLetterbox = "auto",
musicVol = 7, -- 0-7, like the GB's NR50 master volume
sfxVol = 7, -- 0-7
musicFilter = 0, -- low-pass steps, 0 = off
+22
View File
@@ -7,6 +7,28 @@ local SHADES = {
{ 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)
assert(width % 8 == 0 and height % 8 == 0,
("%dbpp dimensions must be tile-aligned: %dx%d")
+1
View File
@@ -605,6 +605,7 @@ local function gen2Rows(opts, hooks, shared)
end
opts.color =
GbcPalette.MODES[wrapIndex(idx - 1 + dir, #GbcPalette.MODES) + 1]
opts.palette = ""
return true
end)
end
+88 -13
View File
@@ -377,12 +377,19 @@ end
-- 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)
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.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)
if isFocused then
local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
Theme.strokeRounded(x - 3, y - 3, w + 6, h + 6, PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, 4)
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 ty = y + (h - Kit.textHeight("button")) / 2
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)
end
end
if action and (Kit.press(x, y, w, h) or Kit._activateId == key) then
queueAction(imp, key, action)
if (Kit.press(x, y, w, h) or Kit._activateId == key) then
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
@@ -1360,7 +1394,9 @@ local function buildHeader(imp, m)
-- under the gear and the quit X -- "the settings is covering the logo".
-- Reserving the space on both sides costs a little width and cannot
-- overlap at any window size.
local clusterW = 2 * gear + math.floor(6 * m.s) + m.pad
-- iOS has no quit button (the OS owns app exit), so the cluster is the
-- gear alone and the wordmark gets that width back
local clusterW = (imp.ios and gear or 2 * gear + math.floor(6 * m.s)) + m.pad
local boxX = m.x + clusterW
local boxW = math.max(0, m.w - 2 * clusterW)
if imp.logo and boxW > 0 then
@@ -1394,8 +1430,8 @@ local function buildHeader(imp, m)
-- inboard of it -- but the two are REGISTERED gear first, because the first
-- focusable of the first frame adopts the keyboard ring and that must not be
-- the button that exits the app.
local quitX = rx - gear
rx = quitX - math.floor(6 * m.s)
local quitX = not imp.ios and rx - gear or nil
if quitX then rx = quitX - math.floor(6 * m.s) end
-- Settings gear. It now also owns the CONTROL settings (touch overlay
-- editor, reset rebinds), which used to be buttons stacked in the game
@@ -1407,7 +1443,7 @@ local function buildHeader(imp, m)
chrome.gear.image = imp._gearIcon
btn(imp, rx, by, gear, gear, "gear", "", chrome.gear)
btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit)
if quitX then btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit) end
-- The self-update control lives in the FOOTER next to the BCG mark (small,
-- out of the wordmark's way -- it used to overlap the logo on a phone). It
@@ -3406,13 +3442,19 @@ local function buildFooter(imp, m, y)
local bx = m.x + math.floor((m.w - topW) / 2)
local my = cy + math.floor((rowH - dh) / 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)
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.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.setShader()
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)
end
local cx = bx + dw
@@ -5604,6 +5646,8 @@ end
-- a click on the scrim lands on whatever button happens to be behind it.
-- Keep this list in sync with buildModals below.
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
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
or imp._appPatchNotes
@@ -5616,6 +5660,14 @@ local function modalUp(imp)
end
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
buildPrompt(imp, m, {
key = "profren", title = Strings("Rename profile"),
@@ -5787,6 +5839,10 @@ end
local function drawPadCursor(imp)
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
-- framebuffer when the stick advances by fractional pixels each frame.
local x, y = imp._padCursor.x, imp._padCursor.y
@@ -5970,6 +6026,25 @@ function LauncherView.draw(imp)
Kit.endFrame()
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
return LauncherView
+10 -2
View File
@@ -1535,6 +1535,14 @@ function RomExtractor:extractYellowTitleArt()
local bg = sheetTiles("TitlePikachuBGGraphics", 64)
local ob = sheetTiles("TitlePikachuOBGraphics", 12)
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)
if id < 0x80 then return logo[id + 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)
ImageWriter.blit(overlay, pikachu, 0, 0, 24, 16, 48, 16)
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
overlays[suffix] = overlay
end
-- open eyes bake into pikachu.png AFTER the blank-face crops
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
self:save(pikachu, "title/pikachu.png")
for suffix, overlay in pairs(overlays) do
+2 -1
View File
@@ -2753,7 +2753,8 @@ function RomExtractorGen2:extractAudio(maps)
local speciesOrder = self.manifest.constants.speciesOrder or {}
local cries = {}
for index, species in ipairs(speciesOrder) do
if species ~= "UNOWN" and not tostring(species):match("^UNUSED") then
-- data/pokemon/cries.asm:209 gives UNOWN a real cry row
if not tostring(species):match("^UNUSED") then
local row = self.rom:bytes(
pokeCryPtr.bank, pokeCryPtr.address + (index - 1) * 6, 6)
local cryIndex = row[1] + row[2] * 256
+481 -35
View File
@@ -9,6 +9,9 @@ local SafeArea = require("src.core.SafeArea")
local 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
-- 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
@@ -1260,7 +1263,12 @@ end
-- up the background worker or reach out to the network.
local function updaterAllowed()
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_IMPORT_ONLY") == "1" then return false end
return true
@@ -1582,8 +1590,11 @@ function RomImporter:focus(f)
.. love.filesystem.getSaveDirectory()
local legacyRequiredPick = self.requiredImportLegacyRomPick
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("/baseroms/", 1, true)
or pickError:find("\\baseroms\\", 1, true)
or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then
self.modNotice = { ok = false, text = text }
self.pickerPendingKind = nil
@@ -1606,6 +1617,22 @@ function RomImporter:focus(f)
end
return
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)
if requiredName then
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
@@ -2033,8 +2060,42 @@ function RomImporter:chooseMod()
end
return
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()
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
local function requiredManifest(self, modId)
@@ -2059,6 +2120,122 @@ local function requiredImportNotice(self, modId, importId, text)
}
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)
local manifest = requiredManifest(self, modId)
if not manifest then
@@ -2218,7 +2395,15 @@ function RomImporter:chooseRequiredImport(modId, importId)
self.pickerPendingModId = modId
self.pickerPendingImportId = importId
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.pickerPendingModId = nil
self.pickerPendingImportId = nil
@@ -2333,8 +2518,42 @@ function RomImporter:chooseSaveImport(version)
end
return
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()
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
-- "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
return
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)
if path then
self:startPath(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 " .. (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
-- 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
@@ -2549,6 +2799,8 @@ function RomImporter:_pollPickedFiles(dt)
return
end
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
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
local n = name:lower()
@@ -2850,6 +3102,14 @@ function RomImporter:_cycleTab(delta)
end
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
self:_ensureNxPointerBridge()
-- 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
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
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
if ny > oy + h then overY = ny - (oy + h)
elseif ny < oy then overY = ny - oy end
@@ -2907,17 +3159,12 @@ function RomImporter:_updatePadCursor(dt)
if overY ~= 0 and self._flex then
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
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
pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y)
self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y
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
if math.abs(ry) > PAD_DEAD and self._flex then
self:_activatePadCursor()
@@ -2926,30 +3173,184 @@ function RomImporter:_updatePadCursor(dt)
end
function RomImporter:gamepadpressed(_, button)
self:_activatePadCursor()
-- Map through GamepadMap so NX swaps SDL face labels to Nintendo A/B.
local action = GamepadMap.mapGamepadButton(button)
if action == "a" then
-- Instant click at the virtual pointer: dispatched straight into the
-- 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)
local action = (GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
if okKit then
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
if Kit.VirtualKeyboard.gamepadpressed(action) then return 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 on handhelds
if button == "back" or button == "select" or action == "select" then
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"
or os.getenv("POKEPORT_SBC") == "1" or os.getenv("ANBERNIC") == "1"
if not isHandheld then return end
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)
return
elseif button == "rightshoulder" then
self:_cycleTab(1)
elseif button == "dpup" or button == "dpdown"
or button == "dpleft" or button == "dpright" then
self._padDir[button] = true
elseif button == "start" or button == "back" then
-- Start / Select: Play if ready, else Choose ROM on the active game tab.
return
end
-- Triggers (L2 / R2): fast scroll content / lists up and down
if button == "triggerleft" or button == "lefttrigger" or button == "l2"
or action == "triggerleft" or action == "l2" then
require("src.import.LauncherView").wheelmoved(self, 0, 4)
return
elseif button == "triggerright" or button == "righttrigger" or button == "r2"
or action == "triggerright" or action == "r2" then
require("src.import.LauncherView").wheelmoved(self, 0, -4)
return
end
-- Start button: Play / Choose ROM
if button == "start" then
if self.workState == "working" then return end
local version = self.tab
if GameVersion.VERSIONS[version] then
if self.ready[version] then self:play(version) else self:_romAction(version) 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
@@ -2963,11 +3364,29 @@ end
function RomImporter:gamepadaxis(_, axis, value)
if axis == "leftx" or axis == "lefty" or axis == "righty" then
self._padAxis[axis] = value
if math.abs(value) > PAD_DEAD then
if self._padCursorActive and math.abs(value) > PAD_DEAD then
self:_activatePadCursor()
elseif axis == "lefty" then
elseif axis == "lefty" and math.abs(value) <= PAD_DEAD then
self._padStickCentered = true
end
elseif axis == "triggerleft" or axis == "lefttrigger" then
if value > 0.4 then
if not self._l2TriggerActive then
require("src.import.LauncherView").wheelmoved(self, 0, 4)
self._l2TriggerActive = true
end
elseif value < 0.2 then
self._l2TriggerActive = false
end
elseif axis == "triggerright" or axis == "righttrigger" then
if value > 0.4 then
if not self._r2TriggerActive then
require("src.import.LauncherView").wheelmoved(self, 0, -4)
self._r2TriggerActive = true
end
elseif value < 0.2 then
self._r2TriggerActive = false
end
end
end
@@ -2995,6 +3414,24 @@ function RomImporter:joystickaxis(joystick, axis, value)
self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then
self:gamepadaxis(joystick, "lefty", value)
elseif axis == 3 or axis == 5 then
if value > 0.4 then
if not self._l2RawActive then
require("src.import.LauncherView").wheelmoved(self, 0, 4)
self._l2RawActive = true
end
elseif value < 0.2 then
self._l2RawActive = false
end
elseif axis == 4 or axis == 6 then
if value > 0.4 then
if not self._r2RawActive then
require("src.import.LauncherView").wheelmoved(self, 0, -4)
self._r2RawActive = true
end
elseif value < 0.2 then
self._r2RawActive = false
end
end
end
@@ -3914,6 +4351,15 @@ function RomImporter:fileUrl(path)
end
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 key == "backspace" then
self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "")
+6
View File
@@ -78,6 +78,12 @@ function SaveFileIO.importToSlot(source, version, force)
version = version or GameVersion.get()
local bytes, readErr = readSource(source)
if not bytes then return false, readErr end
-- The GAME decides before the BYTES do. Everything below this line judges a
-- save by Gen 1's rules -- the size test, and mainChecksumValid, which is
-- pokered's checksum -- so a Gen 2 cart save reaching it is measured against
-- a rule that cannot match and comes back "checksum invalid" (#1832).
local supported, unsupportedWhy = SaveConvert.importSupported(version)
if not supported then return false, unsupportedWhy end
if #bytes ~= SAVE_SIZE then
local check = SaveConvert.mainChecksumValid(bytes)
if check == nil then
+34 -18
View File
@@ -94,9 +94,11 @@ function ItemEffects.healsHP(id)
end
-- .useRareCandy prints over the still-drawn party menu
-- (engine/items/item_effects.asm:1392-1418)
-- (engine/items/item_effects.asm:1392-1418); .useVitamin ends at
-- RemoveUsedItem the same way (engine/items/item_effects.asm:1315-1322)
function ItemEffects.keepsPartyMenuOpen(id)
return ItemEffects.healsHP(id) or id == "RARE_CANDY"
or VITAMINS[id] ~= nil
end
function ItemEffects.isBattleMedicine(id)
@@ -184,6 +186,14 @@ local function adjacentSleepingSnorlax(save, ow)
return nil
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
-- items). data = generated data tables; battle = BattleState when used
-- mid-battle; ow = the overworld (OverworldState), needed only to check
@@ -280,7 +290,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
"All sleeping\nPOKéMON woke up!") }
end
-- battle-only items
-- battle-only items (PrintItemUseTextAndRemoveItem + Heal_Ailment, #1635)
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
or itemId == "POKE_DOLL" then
if not battle then
@@ -293,21 +303,23 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
require("src.world.PikachuFollower")
.modifyHappiness(save, "USEDXITEM", b and b.mon)
end
local used = itemUseLine(data, save, name)
if itemId == "X_ACCURACY" then
-- 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
return "consumed", { Strings("%s's\nhits will never\nmiss!", b.name) }
return "consumed", { used }, USE_JINGLE
end
if X_ITEMS[itemId] then
local stat = X_ITEMS[itemId]
local cur = b.stages[stat] or 0
-- ItemUseXStat removes the item BEFORE running the stat-up
-- effect, so at +6 it is still consumed and StatModifierUpEffect
-- just prints "Nothing happened!"
-- ItemUseXStat: PrintItemUseTextAndRemoveItem, then StatModifierUpEffect
if cur >= 6 then
return "consumed", { romText(data, "_NothingHappenedText",
"Nothing happened!") }
return "consumed", { used }, {
useJingle = true,
afterMessages = { romText(data, "_NothingHappenedText",
"Nothing happened!") },
}
end
b.stages[stat] = cur + 1
b.hazeStatReset = nil
@@ -315,26 +327,29 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
and battle.kind ~= "link" then
require("src.battle.Damage").reapplyBadgeBoosts(b, stat)
end
return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) }
return "consumed", { used }, {
useJingle = true,
afterMessages = { Strings("%s's\n%s rose!", b.name,
Strings(STAT_LABEL[stat])) },
}
end
-- 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
b.focusEnergy = true
return "consumed", { romText(data, "_GettingPumpedText",
"%s's\ngetting pumped!", b.name) }
return "consumed", { used }, USE_JINGLE
end
if itemId == "GUARD_SPEC" then
b.mist = true
return "consumed", { Strings("%s's\nprotected against\nstat changes!", b.name) }
return "consumed", { used }, USE_JINGLE
end
if itemId == "POKE_DOLL" then
if battle.kind ~= "wild" then
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
return "failed", { notTime(data, save) }
end
return "consumed_escape", { romText(data, "_WildRanText",
"The wild POKéMON\nran away!", battle.enemy and battle.enemy.name) }
-- PrintItemUseTextAndRemoveItem then escape
return "consumed_escape", { used }, USE_JINGLE
end
end
@@ -513,8 +528,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- _VitaminStatRoseText's slot order is localization-dependent (the
-- Spanish ROM puts the stat before the name), so the extracted line
-- cannot be filled positionally; the engine wording stands
-- engine/items/item_effects.asm:1313
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
Strings(STAT_LABEL[vitaminStat])) }
Strings(STAT_LABEL[vitaminStat])) }, { useJingle = true }
end
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
@@ -613,7 +629,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if REPELS[itemId] then
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
save.repelSteps = steps
return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
return "consumed", { itemUseLine(data, save, name) }, USE_JINGLE
end
return "failed", { notTime(data, save) }
+26 -4
View File
@@ -23,6 +23,7 @@ local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local TurnOrder = require("src.battle.TurnOrder")
local Strings = require("src.core.Strings")
local Timing = require("src.core.Timing")
local LinkBattle = {}
@@ -473,11 +474,22 @@ function LinkBattle.new(game, net, opts)
-- switches happen before attacks (both may switch)
if myMsg.kind == "switch" then
local idx = myMsg.index
s:act(function() sendOutPlayer(s, myParty[idx]) end)
-- SwitchPlayerMon (engine/battle/core.asm:2419-2423); a post-faint
-- replacement shares sendOutPlayer and prints neither line
s:act(function()
s:sayNextAuto(s:withdrawText(s.player.name), Timing.SWITCH_PLAYER_MON)
s:queueRetreatAnim()
s:actNext(function() sendOutPlayer(s, myParty[idx]) end)
end)
myAction = nil
end
if theirSwitch then
s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
-- SwitchEnemyMon (engine/battle/trainer_ai.asm:596-599)
s:act(function()
s:sayNext(s:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
theirName, s.enemy.name))
s:actNext(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
end)
end
s:act(function()
@@ -885,11 +897,21 @@ function LinkBattle.newSpectator(game, net, opts)
if hostMsg.kind == "switch" then
local idx = hostMsg.index
s:act(function() sendOutHost(s, hostParty[idx]) end)
-- SwitchPlayerMon (engine/battle/core.asm:2419-2423)
s:act(function()
s:sayNextAuto(s:withdrawText(s.player.name), Timing.SWITCH_PLAYER_MON)
s:queueRetreatAnim()
s:actNext(function() sendOutHost(s, hostParty[idx]) end)
end)
end
if guestMsg.kind == "switch" then
local idx = guestMsg.index
s:act(function() sendOutGuest(s, guestParty[idx]) end)
-- SwitchEnemyMon (engine/battle/trainer_ai.asm:596-599)
s:act(function()
s:sayNext(s:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
guestName, s.enemy.name))
s:actNext(function() sendOutGuest(s, guestParty[idx]) end)
end)
end
s:act(function()
+3 -3
View File
@@ -1711,8 +1711,8 @@ local function buildStartMenu()
function overrides.new(game)
local g = live() or game
local save = g and g.save
if save and save.startMenuIndex then
Start2.lastIndex = save.startMenuIndex
if g and g.startMenuIndex then
Start2.lastIndex = g.startMenuIndex
end
return newOrig(g, {
save = save,
@@ -1741,7 +1741,7 @@ COVERAGE["src.ui.StartMenu"] = {
new = "synthesises onClose (stack:pop) and onChoose "
.. "(Game2:openStartMenuItem); without them the menu cannot be left",
index = "the cursor is menu.list.index on Gold (a Chrome.List)",
["save.startMenuIndex"] = "copied INTO StartMenu.lastIndex on construct "
["game.startMenuIndex"] = "copied INTO StartMenu.lastIndex on construct "
.. "and never copied back: Gold's cursor is a class field, not save data",
tx = "the box is fixed at Chrome.box(10, 0, 10, h); tx/ty/tw/th/anchor/"
.. "maxVisible/startCloses/noSound do not exist and writes are inert",
+14 -2
View File
@@ -147,12 +147,19 @@ local SUPPORTED_REQUIRES = {
local ENGINE_PREFIX = (debug.getinfo(1, "S").source or "")
:gsub("^@", ""):gsub("mods[/\\]Loader%.lua$", "")
local ENGINE_CHUNKS = {
["main.lua"] = true,
["conf.lua"] = true,
}
local function callerIsMod(level)
if ENGINE_PREFIX == "" then return false end
local info = debug.getinfo(level, "S")
local source = info and info.source
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
local function scanRequire(name)
@@ -207,6 +214,11 @@ local function engineRequire(name)
return module
end
function Loader.endSession()
devShim.generation = nil
devShim.errors = nil
end
function Loader:_installDevShim()
for id, mod in pairs(self.mods) do
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.
-- Engine code keeps the real module: src/render/PaletteFX.lua:776
-- 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
local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
if adapter then
+5 -1
View File
@@ -826,10 +826,14 @@ R.pokemon = {
item = f.opt(f.id("items")),
species = f.id("pokemon") }),
spriteFront = f.path, spriteBack = f.path, frontSize = f.int(1, 7),
-- text2 is the #DEX entry's second description page (Pokedex_asm's bare
-- `page` macro, engine/pokedex/pokedex.asm): PokedexMenu:drawEntryBody
-- shows `entry.text` on page 1 and `entry.text2` on page 2, so a
-- translation needs both to cover the whole entry.
dexEntry = f.opt(f.rec{ kind = f.str, heightFt = f.int(0),
heightIn = f.int(0, 11), weight = f.num,
heightM = f.opt(f.num), weightKg = f.opt(f.num),
text = f.str }),
text = f.str, text2 = f.opt(f.str) }),
icon = f.opt(f.union{ f.str, f.rec{ image = f.path,
frames = f.opt(f.int(1)) } }),
cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")),
+40 -5
View File
@@ -18,8 +18,6 @@
-- colour IS its palettes -- so one substitution at this seam recolours the
-- 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
-- four hardware shades, and the sheets that are drawn straight
-- (chrome, text) are already those shades, so the screen is
@@ -32,9 +30,12 @@
local GbcPalette = {}
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.CUSTOM_MODE = "custom"
-- rBGP's four shades as the hardware shows them.
local DMG_SHADES = {
{ 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
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
-- 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
-- cart colour while everything on top of it went grey.
function GbcPalette.resolve(colors)
if GbcPalette.customRamp then return GbcPalette.customRamp end
if GbcPalette.mode == "gbc" then return colors end
return DMG_SHADES
end
@@ -249,6 +261,10 @@ function GbcPalette.presentColors()
end
function GbcPalette.setMode(mode)
if mode == GbcPalette.CUSTOM_MODE then
GbcPalette.mode = mode
return mode
end
for _, name in ipairs(GbcPalette.MODES) do
if name == mode then
GbcPalette.mode = mode
@@ -260,7 +276,7 @@ function GbcPalette.setMode(mode)
end
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
-- 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
at = (at - 1 + (delta or 1)) % count + 1
GbcPalette.mode = GbcPalette.MODES[at]
GbcPalette.setCustomRamp(nil)
return GbcPalette.mode
end
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
-- 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
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
+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 gbcPack -- 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
-- 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
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)
mode = mode or PaletteFX.mode
return mode == "redpp"
@@ -453,7 +487,10 @@ function PaletteFX.pal(data, name)
if fromRom then return fromRom end
end
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)
if fromCgb then return fromCgb end
end
@@ -511,7 +548,8 @@ function PaletteFX.monPal(data, species, transformed)
if pal then return pal end
end
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)
if yc then return yc 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
-- pack failed to load at all)
function PaletteFX.hasWorldTileset(tileset)
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
local w = PaletteFX.worldPack()
return (w and w.tileGroups[tileset]) ~= nil
end
-- 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
function PaletteFX.worldGroupAt(tileset, mapId, tileId)
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
local w = PaletteFX.worldPack()
local groups = w and w.tileGroups[tileset]
if not groups then return nil end
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,
-- like pokered's wYCoord check -- data is Game.data, for the map lookup)
function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
local w = PaletteFX.worldPack()
local base = w and w.groupColors[tileset]
if not base then return nil 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
-- same one of the 4 SPR_PAL_* colors.
function PaletteFX.spriteObp(spriteDef, seed)
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
local w = PaletteFX.worldPack()
local src = spriteDef and (spriteDef.paletteSource or spriteDef.source)
if not (w and src) then return nil end
local idx = tonumber(src:match("%[(%d+)%]"))
-- RedBikeSprite and SurfingPikachuSprite load outside
-- SpriteSheetPointerTable, so their source has no bracketed index;
-- they wear the player's OBP palette (spriteAssignment[0]).
if not idx and (src:find("RedBikeSprite", 1, true)
or src:find("SurfingPikachuSprite", 1, true)) then
-- they wear the player's OBP palette (spriteAssignment[0]) on Red/Blue.
-- On Yellow, Surfing Pikachu uses the Pikachu yellow OBJ group (#1639).
if not idx and src:find("RedBikeSprite", 1, true) then
idx = 0
elseif not idx and src:find("SurfingPikachuSprite", 1, true) then
idx = GameVersion.isYellow() and 60 or 0
end
local group = idx and w.spriteAssignment[idx]
if group == nil then return nil end
@@ -761,6 +797,18 @@ function PaletteFX.shadeMap()
return shadeMap
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)
local prev = PaletteFX.mode
local ok = false
@@ -772,27 +820,35 @@ function PaletteFX.setMode(mode)
end
end
if not ok then PaletteFX.mode = "gbc" end
-- battle pics and overworld sprites bake the active pack into ImageData;
-- drop those caches when the pack (or any COLORS mode) changes so the
-- next draw re-tints
if prev ~= PaletteFX.mode then
pcall(function() require("src.battle.BattleState").invalidate() end)
pcall(function() require("src.render.SpriteRenderer").invalidate() end)
-- RED++'s baked tileset atlas (TileRenderer.getGbcAtlas) is built once
-- per loaded map, so a mode toggle needs every cached Map/TileRenderer
-- dropped and the currently-visible one rebuilt in place -- otherwise
-- the on-screen map keeps its stale (wrong-mode) atlas until the next
-- 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)
if prev ~= PaletteFX.mode then invalidateColorCaches() end
end
PaletteFX.customRamp = nil
function PaletteFX.setCustomRamp(ramp)
local prev = PaletteFX.customRamp
PaletteFX.customRamp = ramp
if (prev ~= nil) ~= (ramp ~= nil) or prev ~= ramp then
invalidateColorCaches()
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()
local cur = PaletteFX.mode or "gbc"
local idx = 1
@@ -805,6 +861,13 @@ end
function PaletteFX.applyOptions(opts)
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
function PaletteFX.modeLabel(mode)
@@ -817,12 +880,11 @@ function PaletteFX.modeLabel(mode)
end
-- 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)
if zones and zones[1] then return zones end
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) }
end
return zones
@@ -852,7 +914,9 @@ function PaletteFX.effectiveColors(c)
if not c then return nil end
local mode = PaletteFX.mode or "gbc"
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
elseif mode == "og_inv" then
out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
+25 -2
View File
@@ -332,12 +332,14 @@ end
-- transparent: the world pass shows through (UI pass draws overlays only)
function Renderer:beginFrame(transparent)
self.uiOpaque = not transparent
self.worldActive = false
self.uprightActive = false
self.worldOverride = nil
-- warp-fade overlay from Transition (issue #121); cleared each frame so
-- a popped transition cannot leave a sticky black veil
self.worldFadeAlpha = nil
self.worldFadeColor = nil
-- battle-transition wipe, drawn over the whole surface (BattleTransition)
self.battleWipe = nil
-- whole-surface veil in screen space (battle-transition flash, the
@@ -698,6 +700,18 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
return
end
love.graphics.setShader(shader)
-- data/sgb/sgb_packets.asm:123-127: zone 1 is the whole-screen ATTR_BLK,
-- so the underpaint is only drawn when it leaves part of the box bare (#1866)
local first = zoneList[1]
if canvas == self.canvas and self.uiOpaque and first.colors
and not (bx + first.x * zoneSx <= boxX
and by + first.y * zoneSy <= boxY
and bx + (first.x + first.w) * zoneSx >= boxX + boxW
and by + (first.y + first.h) * zoneSy >= boxY + boxH) then
PaletteFX.sendColors(shader, first.colors)
love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
-- a colors == false zone is the trueColor opt-out: its rect draws with
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
-- list never toggles and issues exactly the calls it always did.
@@ -971,6 +985,13 @@ function Renderer:endFrame(zones, worldZones)
and not FaithfulRes.scaleCap() then
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
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
if cut then
love.graphics.setColor(0, 0, 0, 1)
@@ -1045,7 +1066,8 @@ function Renderer:endFrame(zones, worldZones)
-- the screen-space overlays the flat path draws over its composite
local fade = self.worldFadeAlpha
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.setColor(1, 1, 1, 1)
end
@@ -1132,7 +1154,8 @@ function Renderer:endFrame(zones, worldZones)
-- composite normally if one is ever stacked that way.
local fade = self.worldFadeAlpha
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.setColor(1, 1, 1, 1)
end
+124 -9
View File
@@ -11,6 +11,12 @@ local UIVisibility = require("src.battle.UIVisibility")
local Theme = require("src.ui.Theme")
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 = {}
TextBox.__index = TextBox
TextBox.isTextBox = true
@@ -22,6 +28,52 @@ local MAX_COLS = 18
-- pokegold constants/ram_constants.asm: TEXT_DELAY_FAST/MED/SLOW = 1/3/5
local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
-- TextCommand_PAUSE (home/text.asm:492-504): a mid-string wait callers
-- embed as TextBox.PAUSE, stripped here and re-anchored after pagination.
TextBox.PAUSE = "\1"
local PAUSE_FRAMES = 30
local function glyphs(str)
return #Font.split((str:gsub("[\n\v\f]", "")))
end
local function stripPauses(text)
if not text:find(TextBox.PAUSE, 1, true) then return text, nil end
local out, marks, count, pos = {}, {}, 0, 1
while true do
local i = text:find(TextBox.PAUSE, pos, true)
if not i then
out[#out + 1] = text:sub(pos)
break
end
local chunk = text:sub(pos, i - 1)
out[#out + 1] = chunk
count = count + glyphs(chunk)
marks[#marks + 1] = count
pos = i + 1
end
return table.concat(out), marks
end
-- glyph offsets into the whole text -> [page][line][char]
local function mapPauses(pages, marks)
local at, acc, mi = {}, 0, 1
for pi, page in ipairs(pages) do
for li, line in ipairs(page) do
local n = #Font.split(line)
while mi <= #marks and marks[mi] <= acc + n do
local ci = marks[mi] - acc
at[pi] = at[pi] or {}
at[pi][li] = at[pi][li] or {}
at[pi][li][ci] = mi
mi = mi + 1
end
acc = acc + n
end
end
return at
end
-- opts.choice: when the last page has typed out, a YES/NO ChoiceBox pops
-- up over the still-visible text (YesNoChoicePokeCenter and friends);
-- the box then closes and choice(yes) runs instead of onDone.
@@ -60,6 +112,8 @@ function TextBox.new(game, text, onDone, opts)
self.stay = opts and opts.stay
-- engine/events/hidden_events/cinnabar_gym_quiz.asm:119
self.preSound = opts and opts.preSound
-- pokegold engine/overworld/scripting.asm:485 WaitSFX
self.sfxWait = opts and opts.sfxWait
-- opts.instant: put the LAST page up already typed, with no typewriter and
-- no page waits. A `yesorno` follows a `writetext` that has already been
-- read, so re-typing the line under the YES/NO box would be wrong -- the
@@ -75,7 +129,13 @@ function TextBox.new(game, text, onDone, opts)
self.line1Y = (self.boxTy + 2) * 8
self.line2Y = (self.boxTy + 4) * 8
text = TextBox.substitute(game, text)
local marks
text, marks = stripPauses(text)
self.pages = TextBox.paginate(text, self.maxCols)
-- opts.pauseSounds[i] is the sfx the i-th marker fires once its wait is
-- over (text_asm SFX_SWAP, engine/pokemon/learn_move.asm:210-213)
self.pauseSounds = opts and opts.pauseSounds
self.pauseAt = marks and mapPauses(self.pages, marks) or nil
self.pageIndex = 1
self.lineIndex = 1
self.charIndex = 0
@@ -270,6 +330,14 @@ function TextBox:visibleText()
return #out > 0 and out or nil
end
-- pokegold engine/overworld/scripting.asm:484-485 PlaySFX / WaitSFX
function TextBox:sfxHeld()
if not self.sfxWait then return false end
if require("src.core.Sound").sfxBusy() then return true end
self.sfxWait = nil
return false
end
function TextBox:update(dt)
local input = self.game.input
self.blink = (self.blink + 1) % 60
@@ -291,6 +359,20 @@ function TextBox:update(dt)
self.holdFrames = self.holdFrames - 1
return
end
-- home/text.asm:492
if self.pauseFrames then
if self.pauseFrames > 0 then
self.pauseFrames = self.pauseFrames - 1
return
end
self.pauseFrames = nil
local snd = self.pauseSounds and self.pauseSounds[self.pauseMark]
if type(snd) == "function" then
snd()
elseif snd then
require("src.core.Sound").play(self.game.data, snd)
end
end
if self.done then
-- opts.stay: the box is finished but stays up under whatever the caller
-- pushed over it; StateStack updates the top state only, so this runs
@@ -372,6 +454,7 @@ function TextBox:update(dt)
end
return
end
if self:sfxHeld() then return end
if input:wasPressed("a") or input:wasPressed("b") then
require("src.core.Sound").play(self.game.data, "Press_AB")
self.game.stack:pop()
@@ -387,6 +470,7 @@ function TextBox:update(dt)
self.preWait = self.preWait - 1
return
end
if self:sfxHeld() then return end
if input:wasPressed("a") or input:wasPressed("b") then
require("src.core.Sound").play(self.game.data, "Press_AB")
self.waiting = false
@@ -424,6 +508,15 @@ function TextBox:update(dt)
self.charIndex = self.charIndex + 1
local line = self.shown[#self.shown]
line[#line + 1] = self.codes[self.charIndex]
local marks = self.pauseAt and self.pauseAt[self.pageIndex]
marks = marks and marks[self.lineIndex]
if marks and marks[self.charIndex] then
self.pauseMark = marks[self.charIndex]
-- TextCommand_PAUSE reads hJoyHeld, so a held A/B skips the wait
self.pauseFrames = (input:isDown("a") or input:isDown("b"))
and 0 or PAUSE_FRAMES
break
end
else
-- line finished
local page = self.pages[self.pageIndex]
@@ -470,8 +563,21 @@ function TextBox:draw()
-- (pokegold engine/pokegear/pokegear.asm TownMapPals sends every tile
-- >= $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()
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
self.scrollPx = self.scrollPx - 2
if self.scrollPx <= 0 then self.scrollPx = nil end
@@ -491,17 +597,25 @@ function TextBox:draw()
-- measured with; every fixed-width page still lands on the 8px grid
local pen = self.textX
for _, code in ipairs(line) do
Font.drawCode(code, pen, y)
drawGlyph(code, pen, y)
pen = pen + Font.advanceOf(code)
end
end
if self.money then
-- money box (engine/menus/text_box.asm:130): DisplayMoneyBox at
-- hlcoord 11,0, the amount right-aligned on its middle row
Font.drawBox(11, 0, 9, 3)
love.graphics.setColor(0, 0, 0, 1)
if Chrome then
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)
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
if (self.waiting or (self.done and not self.choice and not self.auto
and (not self.stay
@@ -509,10 +623,11 @@ function TextBox:draw()
and self.blink < 30 then
-- page-advance cursor: glyph $EE by default, the blinking down arrow
-- the original prints via `ld a, "▼"` (home/text.asm)
Font.drawCode(Theme.moreArrow or 0xEE,
(self.boxTx + self.boxTw - 2) * 8,
(self.boxTy + self.boxTh - 1) * 8 - 4)
drawGlyph(Theme.moreArrow or 0xEE,
(self.boxTx + self.boxTw - 2) * 8,
(self.boxTy + self.boxTh - 1) * 8 - 4)
end
if finishGlyph then finishGlyph() end
love.graphics.setColor(1, 1, 1, 1)
end
+3 -6
View File
@@ -150,13 +150,10 @@ end
-- true while the spinner arrow tiles should show the 'blur' graphic; false
-- means draw nothing extra (the static window tile shows through,
-- matching the asm's restore-to-original behavior). The 8-tick
-- half-period approximates one GB movement step (2px/frame); this is a
-- deliberate approximation of wSimulatedJoypadStatesIndex bit-0 parity, not
-- a cycle-accurate replication -- the port's tweened scriptMove has no
-- direct equivalent discrete step counter.
-- matching the asm's restore-to-original behavior).
-- spinners.asm:17-22, home/overworld.asm:1844-1846, :49-52
function TileRenderer.spinBlurActive()
return spinning and (math.floor(animFrame / 8) % 2 == 0)
return spinning and (math.floor(animFrame / 16) % 2 == 0)
end
-- ------------------------------------------------------------------
+14 -4
View File
@@ -65,16 +65,24 @@ end
-- (ViridianGym.asm .afterBeat, RocketHideoutB4F BeatGiovanniScript) call
-- GBFadeOutToBlack -> GBFadeInFromBlack instead, so the default keeps the
-- 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)
self.game = game
self.onMidpoint = onMidpoint
self.onDone = onDone
self.t = 0
self.phase = "out"
self.color = opts.color or { 0, 0, 0 }
local style = styleOf(game, "warp_fade")
self.frames = style.frames or FRAMES
if warp then
self.frames = opts.frames or style.frames or FRAMES
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
-- data-driven); the built-in warp is 0, matching hardware
self.framesIn = style.framesIn or FRAMES_IN
@@ -125,6 +133,7 @@ end
function Transition:draw()
local alpha = self:alpha()
local c = self.color or { 0, 0, 0 }
-- Survey zoom draws the overworld into a window-filling world canvas
-- 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
@@ -133,9 +142,10 @@ function Transition:draw()
local r = self.game and self.game.renderer
if r and r.worldActive then
r.worldFadeAlpha = alpha
r.worldFadeColor = c
return
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.setColor(1, 1, 1, 1)
end
+33 -8
View File
@@ -235,6 +235,35 @@ local function gen2CartName(gameVersion)
return GameVersion.info(gameVersion).displayName
end
-- Can a cart save for this game cross in or out at all? Public because the
-- launcher has to ask about the GAME before it measures the BYTES.
--
-- SaveFileIO.importToSlot judges anything that is not exactly SAVE_SIZE with
-- mainChecksumValid, which is pokered's main-data checksum. A Gen 2 cart is
-- MBC3+TIMER, so a real Gold/Silver/Crystal .sav carries an RTC footer and is
-- 32786 bytes: it misses the size test and is then measured against a rule
-- that was never going to match it. The player is told "save data checksum
-- invalid" about a save that is perfectly good (#1832).
--
-- Returns true, or false plus the same sentence importSav/exportSav would have
-- answered with, so a caller that asks early and a caller that does not cannot
-- describe the same game two different ways.
function SaveConvert.importSupported(gameVersion)
local gen2Name = gen2CartName(gameVersion)
if gen2Name then
return false, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet."
end
return true
end
function SaveConvert.exportSupported(gameVersion)
local gen2Name = gen2CartName(gameVersion)
if gen2Name then
return false, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet."
end
return true
end
-- importSav(bytes, version, gameVersion) -> saveTable, err
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
-- checksum, decodes through GenSave, and returns a save table fully merged
@@ -247,10 +276,8 @@ function SaveConvert.importSav(bytes, version, gameVersion)
if type(bytes) ~= "string" then
return nil, "expected raw save bytes as a string"
end
local gen2Name = gen2CartName(gameVersion)
if gen2Name then
return nil, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet."
end
local supported, unsupportedWhy = SaveConvert.importSupported(gameVersion)
if not supported then return nil, unsupportedWhy end
if #bytes ~= GenSave.SAVE_SIZE then
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
end
@@ -283,10 +310,8 @@ function SaveConvert.exportSav(saveTable, gameVersion)
if type(saveTable) ~= "table" then
return nil, "expected a save table"
end
local gen2Name = gen2CartName(gameVersion)
if gen2Name then
return nil, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet."
end
local supported, unsupportedWhy = SaveConvert.exportSupported(gameVersion)
if not supported then return nil, unsupportedWhy end
local data, derr = ensureData(gameVersion)
if not data then return nil, derr end
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
+6 -5
View File
@@ -451,12 +451,13 @@ function H.CutDownTreeOrGrass(ctx)
end
-- DisappearWhirlpool is CutDownTreeOrGrass with PlayWhirlpoolSound in place of
-- OWCutAnimation: the same block write, the same redraw, then the surf wash the
-- port owns as World:playWhirlpoolSound. #1717
-- OWCutAnimation, and the block only reaches the screen after the sound
-- -- engine/events/overworld.asm:1157-1164 (#1717, #1862)
function H.DisappearWhirlpool(ctx)
local ret = H.CutDownTreeOrGrass(ctx)
call(ctx, "playWhirlpoolSound")
return ret
local index = ctx and ctx.cutWhirlpoolBlockIndex
local blockId = ctx and ctx.cutWhirlpoolReplacement
call(ctx, "playWhirlpoolSound", index, blockId)
return nil
end
-- BlindingFlash sets STATUSFLAGS_FLASH_F and reloads the palettes. Setting
+12 -6
View File
@@ -152,6 +152,12 @@ local function answer(vm, value)
vm.scriptVar = value or 0
end
-- WaitSFX (pokegold home/audio.asm); a test stub that calls a handler off
-- the coroutine has no sfx to drain.
local function drainSfx()
if coroutine.running() then coroutine.yield({ kind = "waitsfx" }) end
end
-- Every routine that ends `call GetPokemonName / jp
-- CopyPokemonName_Buffer1_Buffer3` puts a name where the next writetext's
-- {STRBUF} will find it.
@@ -452,8 +458,10 @@ H.BugContestJudging = function(vm)
local what = (h.monName and h.monName(entry.species)) or entry.species
or ""
vm:showRaw(Strings(row.page, who, what))
-- pokegold engine/events/bug_contest/judging.asm:29-32
drainSfx()
if h.playSfxNamed then h.playSfxNamed(row.sfx) end
vm:showRaw(Strings(row.score, entry.score or 0))
vm:showRaw(Strings(row.score, entry.score or 0), nil, nil, true)
end
end
answer(vm, place)
@@ -2033,12 +2041,10 @@ H.ProfOaksPCBoot = function(vm)
vm:showRaw(Strings(OAK_PC_TEXT.counts, seen, caught))
local rating = findOakRating(caught)
local h = hooks(vm)
-- pokegold engine/events/prof_oaks_pc.asm:18-20 PlaySFX / JoyWaitAorB / WaitSFX
drainSfx()
if h.playSfxNamed then h.playSfxNamed(rating.sfx) end
vm:showRaw(Strings(rating.text))
-- `call PlaySFX / call JoyWaitAorB / call WaitSFX`: the fanfare is left
-- playing under the rating text, and the caller (OaksLab's own script)
-- waits it out before closing the box.
coroutine.yield({ kind = "waitsfx" })
vm:showRaw(Strings(rating.text), nil, nil, true)
end
-- The whose-PC menu's PROF.OAK's PC row (src/ui/gen2/CenterPcMenu.lua) runs
+10 -6
View File
@@ -700,8 +700,10 @@ local function runCmd(self, cmd, op)
-- GetPocketName fills from ItemPocketNames: KEY ITEMs, BALLs and TMs
-- name their own pocket, not the ITEM one (data/text/common_2.asm
-- :1351, data/items/pocket_names.asm:10-13).
-- Script_specialsound's WaitSFX (scripting.asm:485): the box holds
-- its press until the jingle ends.
self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.",
name, self:pocketName(item)))
name, self:pocketName(item)), nil, nil, true)
else
self:showRaw(Strings("The %s\nis full…", self:pocketName(item)))
end
@@ -1510,8 +1512,8 @@ local function runCmd(self, cmd, op)
self.playSoundFn(SFX_ITEM)
end
-- FruitTreeScript's tail is `specialsound / itemnotify` with NOTHING
-- between them (engine/events/fruit_trees.asm:23-24), and Script_specialsound
-- is a bare PlaySFX -- it does not wait either (scripting.asm:476-483).
-- between them (engine/events/fruit_trees.asm:23-24);
-- Script_specialsound ends PlaySFX / WaitSFX (scripting.asm:484-485)
-- The port used to park here on a `waitsfx`, which is the same seam
-- GiveItemScript's did: this port's box takes its own button and pops on
-- it, so the park ran with an EMPTY state stack and the bare overworld
@@ -1523,7 +1525,7 @@ local function runCmd(self, cmd, op)
-- noun still comes from ItemPocketNames rather than from a third copy of
-- the literal (data/items/pocket_names.asm:10-13).
self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.",
name, self:pocketName(item)))
name, self:pocketName(item)), nil, nil, true)
return "end"
elseif op == "describedecoration" then
-- `describedecoration byte` picks one of five DECODESC_* arms
@@ -2427,7 +2429,7 @@ end
-- the port's world does not tick while a box is on the stack (Game2:update
-- stops at the top state), so the only clock that can count it is the box's;
-- World:showText is where it lands.
function Vm:showRaw(body, stay, hold)
function Vm:showRaw(body, stay, hold, sfxWait)
if not body or body == "" then body = "..." end
if self.stringBuffer and self.stringBuffer ~= "" then
body = body:gsub("{STRBUF}", self.stringBuffer)
@@ -2438,6 +2440,8 @@ function Vm:showRaw(body, stay, hold)
text = body,
stay = (stay or self:textStays()) and true or false,
hold = hold,
-- pokegold engine/overworld/scripting.asm:485 WaitSFX
sfxWait = sfxWait and true or nil,
})
end
end
@@ -2646,7 +2650,7 @@ function Vm:resume(resumeValue)
if req and req.kind == "text" and self.showTextFn then
self.showTextFn(req.text, function()
self:resume()
end, req.stay, req.hold)
end, req.stay, req.hold, req.sfxWait)
elseif req and req.kind == "wait" then
self.waitLeft = req.frames or 0
elseif req and req.kind == "waitbutton" then
+44 -8
View File
@@ -116,6 +116,36 @@ function SyncEngine.overlaps(a, b)
return aStart <= bEnd and bStart <= aEnd
end
-- Inline, under .meta, or under .remoteMeta, depending on the endpoint.
function SyncEngine.metaOf(row)
if type(row) ~= "table" then return nil end
if type(row.meta) == "table" then return row.meta end
if type(row.remoteMeta) == "table" then return row.remoteMeta end
return row
end
-- Gen 2 stores playTime as a table, so meta.playTime is nil on a Gold save
-- and summary.timeText is the only field that survives.
local function playedMinutes(meta)
if type(meta) ~= "table" then return nil end
local summary = type(meta.summary) == "table" and meta.summary or nil
local text = summary and summary.timeText
if type(text) == "string" then
local hours, minutes = text:match("^(%d+):(%d%d)$")
if hours then return tonumber(hours) * 60 + tonumber(minutes) end
end
local seconds = tonumber(meta.playTime)
if seconds then return math.floor(seconds / 60) end
return nil
end
-- Same minute of playtime means the same point in the playthrough, so there
-- is no fork. Unknown playtime on either side is NOT a match.
function SyncEngine.samePlaytime(a, b)
local left, right = playedMinutes(a), playedMinutes(b)
return left ~= nil and left == right
end
function SyncEngine.new(opts)
opts = opts or {}
local eng = setmetatable({}, SyncEngine)
@@ -457,7 +487,11 @@ function SyncEngine:_planFrom(remoteState)
if not row then
self:_queueUpload(entry, key, false)
elseif localChanged and remoteChanged then
self:_addConflict(entry, key, row)
if SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then
self:_queueUpload(entry, key, true)
else
self:_addConflict(entry, key, row)
end
elseif localChanged then
self:_queueUpload(entry, key, false)
elseif remoteChanged and key ~= self.protectedKey then
@@ -477,12 +511,7 @@ function SyncEngine:_planFrom(remoteState)
end
function SyncEngine:_addConflict(entry, key, row)
local remoteMeta = row
if type(row.meta) == "table" then
remoteMeta = row.meta
elseif type(row.remoteMeta) == "table" then
remoteMeta = row.remoteMeta
end
local remoteMeta = SyncEngine.metaOf(row)
self.conflicts[#self.conflicts + 1] = {
key = key,
version = entry.version,
@@ -528,7 +557,14 @@ function SyncEngine:_queueUpload(entry, key, force)
end, function(e, res)
if res.code == 409 then
local row = res.data or {}
e:_addConflict(entry, key, row)
-- Retried with force only once: a forced write that still 409s is a
-- real refusal, and retrying it would spin.
if not force
and SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then
e:_queueUpload(entry, key, true)
else
e:_addConflict(entry, key, row)
end
if not e:busy() then e:_finish() end
return true
end
+22 -5
View File
@@ -54,6 +54,21 @@ local function showMessages(game, msgs, onDone, opts)
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone, opts))
end
-- PrintItemUseTextAndRemoveItem: used-line TextBox carries Heal_Ailment, then
-- optional afterMessages (X Stat effect line) before onDone (#1635).
local function showUseMessages(game, msgs, onDone, extra)
local opts = (extra and extra.useJingle)
and TextBox.soundOpts(game, "Heal_Ailment") or nil
local after = extra and extra.afterMessages
if after and #after > 0 then
showMessages(game, msgs, function()
showMessages(game, after, onDone)
end, opts)
else
showMessages(game, msgs, onDone, opts)
end
end
-- run the use-flow for an item on a chosen target. `picker` is the party
-- menu when it was opened with keepOpen (HP medicine only): it is still on
-- the stack, so every exit that prints has to close it afterwards. For
@@ -115,7 +130,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
if result == "consumed_escape" then -- Poké Doll
consume(game, id, list)
list:close()
showMessages(game, payload, function()
showUseMessages(game, payload, function()
-- ItemUsePokeDoll sets wEscapedFromBattle and never touches
-- wBattleResult, so a script that reads the result afterwards sees
-- 0 -- "defeated". The ghost MAROWAK's script keys on exactly that
@@ -125,7 +140,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
battle.result = "run"
battle.afterQueue = "finish"
battle.phase = "messages"
end)
end, extra)
return
end
@@ -345,6 +360,8 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
-- .useItem_partyMenu jumps back to StartMenu_Item once UseItem
-- returns, cursor still on the candy (start_sub_menus.asm) -- so
-- mashing A burns through a stack of them (#796)
-- RareCandyText carries sound_get_item_1
-- (engine/menus/party_menu.asm:289-293)
showMessages(game, payload, function()
local StatBox = require("src.battle.BattleState").StatBox
game.stack:push(StatBox.new(game, target, function()
@@ -385,7 +402,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
end
nextStep()
end))
end)
end, TextBox.soundOpts(game, "Get_Item1"))
return
end
-- HP medicine: fill the bar in the still-open picker first, then print
@@ -403,9 +420,9 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
end
if battle then
list:close()
showMessages(game, payload, function() battle:itemUsed({}) end)
showUseMessages(game, payload, function() battle:itemUsed({}) end, extra)
else
showMessages(game, payload, closePicker)
showUseMessages(game, payload, closePicker, extra)
end
return
end
+85 -41
View File
@@ -13,9 +13,21 @@ local Strings = require("src.core.Strings")
local BoxMenu = {}
local function monLabel(game, mon)
-- PrintListMenuEntries prints the nickname at hlcoord 6,4 and PrintLevel
-- one row down, 8 columns right (home/list_menu.asm:364-365, 459-461)
local function monRow(game, mon, value)
local def = game.data.pokemon[mon.species]
return Strings("%s :L%d", mon.nickname or def.name, mon.level)
return {
label = mon.nickname or def.name,
sub = Strings(":L%d", mon.level),
value = value,
}
end
-- the $ff terminator's row (home/list_menu.asm:371-372, 523-528)
local function withCancel(items)
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
return items
end
local function monName(game, mon)
@@ -61,14 +73,15 @@ local function withdraw(game)
return
end
local items = {}
for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game,
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
for i, mon in ipairs(box) do table.insert(items, monRow(game, mon, i)) end
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_withdraw",
-- DisplayMonListMenu draws LIST_MENU_BOX over the PC menu, which stays
-- visible around it (home/list_menu.asm:29-31)
itemBox = true,
onChoose = function(item, list)
if item.cancel then list:close() return end
local mon = box[item.value]
if not mon then return end
monSubmenu(game, "WITHDRAW", mon, function()
@@ -111,12 +124,14 @@ local function deposit(game)
end
local items = {}
for i, mon in ipairs(game.save.party) do
table.insert(items, { label = monLabel(game, mon), value = i })
table.insert(items, monRow(game, mon, i))
end
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_deposit",
itemBox = true,
onChoose = function(item, list)
if item.cancel then list:close() return end
local mon = game.save.party[item.value]
if not mon then return end
local Follower = require("src.world.PikachuFollower")
@@ -163,14 +178,13 @@ local function release(game)
return
end
local items = {}
for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game,
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
for i, mon in ipairs(box) do table.insert(items, monRow(game, mon, i)) end
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_release",
onChoose = function(_, list)
itemBox = true,
onChoose = function(item, list)
if item.cancel then list:close() return end
local mon = box[list.index]
if not mon then return end
local name = monName(game, mon)
@@ -202,37 +216,67 @@ local function release(game)
}))
end
local function changeBox(game)
-- DisplayChangeBoxMenu: engine/menus/save.asm:437-506
local function changeBoxMenu(game)
local boxes = Boxes.ensure(game.save)
local items = {}
for i = 1, Boxes.COUNT do
local mark = i == game.save.currentBox and "*" or " "
table.insert(items, {
label = Strings("%sBOX %2d", mark, i),
right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY),
value = i,
})
items[i] = {
label = Strings("BOX%2d", i),
onSelect = function()
game.save.currentBox = i
if game.writeSave then game:writeSave() end
-- ChangeBox (engine/menus/save.asm) rings SFX_SAVE after SaveGameData (#1044)
require("src.core.Sound").play(game.data, "Save")
end,
}
end
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_change",
onChoose = function(item, list)
-- the original asks BEFORE switching ("When you change a #MON
-- BOX, data will be saved. OK?"); declining aborts the change
game.stack:push(TextBox.new(game,
Strings("When you change a\nPOKéMON BOX, data\nwill be saved. OK?"), nil, {
noSound = true,
choice = function(yes)
if not yes then return end
game.save.currentBox = item.value
if game.writeSave then game:writeSave() end
-- ChangeBox (engine/menus/save.asm) rings SFX_SAVE after SaveGameData (#1044)
require("src.core.Sound").play(game.data, "Save")
list:close()
end,
}))
local menu = Menu.new(game, items, {
tx = 11, ty = 0, tw = 9, th = 14, rowStep = 1, itemY = 1, noSound = true,
})
menu.kind = "pc_box_change" -- screen.render_visible identity
menu.index = math.max(1, math.min(Boxes.COUNT, game.save.currentBox or 1))
local t = game.data.text
local baseDraw = menu.draw
function menu:draw()
-- ChooseABoxText goes to the standard box, over BillsPCMenu's "What?"
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
local y = 112
for line in ((t._ChooseABoxText or Strings("Choose a\nPOKéMON BOX."))
.. "\n"):gmatch("([^\n]*)\n") do
Font.draw(line, 8, y)
y = y + 16
end
Font.drawBox(0, 0, 11, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("BOX No."), 8, 16)
local n = game.save.currentBox or 1
Font.draw(tostring(n), n >= 10 and 64 or 72, 16)
baseDraw(self)
love.graphics.setColor(0, 0, 0, 1)
for i = 1, Boxes.COUNT do
if #boxes[i] > 0 then ListMenu.drawBall(148, i * 8 + 4) end
end
love.graphics.setColor(1, 1, 1, 1)
end
game.stack:push(menu)
end
-- ChangeBox (engine/menus/save.asm:358-368) asks before showing the menu
-- and returns to the PC menu on No.
local function changeBox(game)
local t = game.data.text
local ask = TextBox.new(game, t._WhenYouChangeBoxText
or Strings("When you change a\nPOKéMON BOX, data\vwill be saved.\fIs that okay?"),
nil, {
noSound = true,
choice = function(yes)
if yes then changeBoxMenu(game) end
end,
}))
})
ask.kind = "pc_box_change"
game.stack:push(ask)
end
-- bills_pc.asm BillsPCMenu chrome: What? text box + BOX No. overlay
+37 -5
View File
@@ -87,6 +87,15 @@ local function frameSheet(game)
return frameCache[path]
end
-- one tile of the sheet, for the CONTENTS screen's divider column
-- (engine/menus/pokedex.asm:350, DrawPokedexVerticalLine)
function DexEntryMenu.tile(game, code, tx, ty)
local frame = frameSheet(game)
if not (frame and code) then return end
local quad = frame.quads[code - 0x60]
if quad then love.graphics.draw(frame.img, quad, tx * 8, ty * 8) end
end
-- engine/menus/pokedex.asm:601
local DIVIDER = {
0x68, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x6B,
@@ -106,14 +115,28 @@ function DexEntryMenu.new(game, speciesOrOpts, onDone)
self.sprite = ok and img or nil
self.spriteTrueColor = self.sprite and trueColor or false
self.page = 1
self.blink = 0
local pages = descPages(game, self.def, forceOwned)
self.pageCount = pages and #pages or 1
require("src.core.Sound").playCry(game.data, species)
-- engine/menus/pokedex.asm:500-506, home/pokemon.asm:145-148
self.crySrc = require("src.core.Sound").playCry(game.data, species)
return self
end
-- home/pokemon.asm:148 (jp WaitForSoundToFinish)
function DexEntryMenu:crying()
local src = self.crySrc
if not src then return false end
local ok, playing = pcall(src.isPlaying, src)
if ok and playing then return true end
self.crySrc = nil
return false
end
function DexEntryMenu:update(dt)
local input = self.game.input
self.blink = ((self.blink or 0) + 1) % 60
if self:crying() then return end
if input:wasPressed("a") or input:wasPressed("b") then
-- home/text.asm:245
if self.page < (self.pageCount or 1) then
@@ -127,15 +150,18 @@ end
function DexEntryMenu:draw()
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
self.spriteTrueColor, self.page)
self.spriteTrueColor, self.page,
{ crying = self:crying(),
arrow = (self.blink or 0) < 30 })
end
-- Static entry-page renderer, shared with the printer stand-in
-- (src/core/Printer.lua renders the same page into a PNG the way
-- PrintPokedexEntry rendered it to the Game Boy Printer).
-- engine/menus/pokedex.asm:399
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page, state)
page = page or 1
state = state or {}
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local frame = frameSheet(game)
@@ -185,6 +211,12 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0),
16, 64)
local owned = ownedFor(game, def, forceOwned)
-- engine/menus/pokedex.asm:516: everything below the divider waits on the
-- cry the line above it started
if state.crying then
love.graphics.setColor(1, 1, 1, 1)
return
end
-- engine/menus/pokedex.asm:449, numbers only once owned
if owned and e.heightFt then
if e.heightM then
@@ -202,8 +234,8 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
for i, line in ipairs(lines) do
Font.draw(line, 8, 72 + i * 16)
end
-- home/text.asm:245
if page < #pages then
-- home/text.asm:245 places the '▼' and home/joypad2.asm:55-83 blinks it
if page < #pages and state.arrow ~= false then
Font.drawCode(Theme.moreArrow, 144, 128)
end
else
+41 -26
View File
@@ -120,8 +120,9 @@ function ListMenu.new(game, title, items, opts)
-- so their lists opt out of the A/B beep the same way Menu's noSound does
self.noSound = opts.noSound or false
-- the bag's item list: a partial box the map stays visible around, not a
-- screen of its own (home/list_menu.asm:29-31)
self.itemBox = opts.itemBox or false
-- screen of its own (home/list_menu.asm:29-31). Every DisplayListMenuID
-- caller gets the same box, the PC item lists included (#1845).
self.itemBox = opts.itemBox or opts.messageBox or false
if self.itemBox then
self.isOpaque = false
-- keep RunDefaultPaletteCommand's last palette: ItemMenuLoop never sets
@@ -256,6 +257,34 @@ function ListMenu:close()
if top == self then self.game.stack:pop() end
end
-- standard bottom text box (PrintText); long prompts wrap and keep
-- their last two lines, like the GB's scrolled box (#115/#174)
local function drawMessageBox(self)
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if not self.footer then return end
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
end
local y = 112
for i = math.max(1, #flat - 1), #flat do
Font.draw(flat[i], 8, y)
y = y + 16
end
end
-- the Pokédex owned-ball marker tile, also the CHANGE BOX screen's
-- PokeballTileGraphics marker (engine/menus/save.asm:495-499)
function ListMenu.drawBall(x, y)
local r, g, b, a = love.graphics.getColor()
love.graphics.circle("fill", x, y, 3.5)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", x - 3.5, y - 0.5, 7, 1)
love.graphics.circle("fill", x, y, 1.2)
love.graphics.setColor(r, g, b, a)
end
-- PrintListMenuEntries, minus the price column StartMenu_Item never asks for
-- (wPrintItemPrices = 0, engine/menus/start_sub_menus.asm)
function ListMenu:drawItemBox()
@@ -274,7 +303,10 @@ function ListMenu:drawItemBox()
if item.cancel then sawCancel = true end
local y = ITEM_TOP_Y + (row - 1) * 16
Font.draw(item.label, ITEM_NAME_X, y)
if item.right then
if item.sub then
-- PrintLevel, one row down and 8 columns right (home/list_menu.asm:459-461)
Font.draw(item.sub, ITEM_QTY_X, y + 8)
elseif item.right then
-- '×' at column 14, PrintNumber's two right-aligned digits after it
-- (home/list_menu.asm:479-490)
local count = item.right:sub(2)
@@ -294,6 +326,9 @@ function ListMenu:drawItemBox()
if shown == self.rows and not sawCancel then
Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y)
end
-- players_pc.asm:97/151/205 PrintText the prompt before DisplayListMenuID,
-- so the bottom box sits under the list from the first frame
if self.messageBox or self.footer then drawMessageBox(self) end
love.graphics.setColor(1, 1, 1, 1)
end
@@ -326,13 +361,7 @@ function ListMenu:draw()
-- one blank glyph after the name, measured in glyph advances rather
-- than bytes: NIDORAN♂/♀ carry a multi-byte charmap entry, so
-- `#item.label` overcounted by 2 and pushed their ball 16px right (#285)
local bx = 16 + Font.width(label) + 8 + 3
local by = y + 3
love.graphics.circle("fill", bx, by, 3.5)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", bx - 3.5, by - 0.5, 7, 1)
love.graphics.circle("fill", bx, by, 1.2)
love.graphics.setColor(unpack(textColor))
ListMenu.drawBall(16 + Font.width(label) + 8 + 3, y + 3)
end
if item.right then
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
@@ -360,22 +389,8 @@ function ListMenu:draw()
local money = ("¥%d"):format(self.money and self.money() or 0)
Font.draw(money, 152 - Font.width(money), 8)
end
if self.dialogue or (self.messageBox and self.footer) then
-- standard bottom text box (PrintText); long prompts wrap and keep
-- their last two lines, like the GB's scrolled box (#115/#174)
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if self.footer then
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
end
local y = 112
for i = math.max(1, #flat - 1), #flat do
Font.draw(flat[i], 8, y)
y = y + 16
end
end
if self.dialogue then
drawMessageBox(self)
elseif self.footer then
-- bare footer (bag money line, etc.)
local flat = {}
+49
View File
@@ -0,0 +1,49 @@
-- Scrolling for a menu line too long for its box: hold, step one character
-- at a time, hold on the tail, snap back. Shared by the Gen 1 four-box
-- viewport (src/ui/OptionRows.lua) and the Gen 2 textbox
-- (src/ui/gen2/OptionsMenu.lua) -- different widths, same problem.
local Marquee = {}
Marquee.HOLD = 1.0
Marquee.STEP = 0.25
-- Only ever one row scrolls (the cursor's), so one shared phase keeps that
-- row's label and value stepping together.
local phase = { key = nil, start = 0 }
local function clock()
local timer = love and love.timer and love.timer.getTime
return timer and timer() or 0
end
-- Pure, so a test can step it without a clock.
function Marquee.at(text, maxChars, elapsed)
text = text or ""
local over = #text - maxChars
if over <= 0 then return text end
local span = Marquee.HOLD * 2 + over * Marquee.STEP
local now = elapsed % span
local offset
if now < Marquee.HOLD then
offset = 0
elseif now < Marquee.HOLD + over * Marquee.STEP then
offset = math.floor((now - Marquee.HOLD) / Marquee.STEP)
else
offset = over
end
return text:sub(offset + 1, offset + maxChars)
end
-- `key` identifies the highlighted row; changing it restarts the cycle.
function Marquee.scroll(text, maxChars, key)
if not text or #text <= maxChars then return text or "" end
if phase.key ~= key then phase.key, phase.start = key, clock() end
return Marquee.at(text, maxChars, clock() - phase.start)
end
function Marquee.clip(text, maxChars)
return (text or ""):sub(1, maxChars)
end
return Marquee
+5 -2
View File
@@ -120,14 +120,17 @@ function MoveLearnMenu:finish(learned)
local opts
if learned then
-- pokered pages this as four texts in a row; _ForgotAndText carries
-- the "And..." tail
-- the "And..." tail. Each text_pause holds the box, and the first one
-- is followed by SFX_SWAP (engine/pokemon/learn_move.asm:208-222).
msg = romText(game.data, "_OneTwoAndText", "1, 2 and...")
.. TextBox.PAUSE
.. romText(game.data, "_PoofText", " Poof!")
.. TextBox.PAUSE
.. romText(game.data, "_ForgotAndText",
"\f%s forgot\n%s!\fAnd...", name, self.forgot)
.. "\f" .. romText(game.data, "_LearnedMove1Text",
"%s learned\n%s!", name, mdef.name)
opts = { auto = { sound = function()
opts = { pauseSounds = { "Swap" }, auto = { sound = function()
return require("src.core.Sound").play(game.data, self.learnedSound)
end, wait = true } }
else
+19 -3
View File
@@ -7,12 +7,18 @@
-- rows that open something instead (MODS, CANCEL stays the caller's).
local Font = require("src.render.Font")
local Marquee = require("src.ui.Marquee")
local Theme = require("src.ui.Theme")
local OptionRows = {}
OptionRows.VISIBLE = 4 -- option boxes on screen at once (4 tiles each)
-- Font.drawBox(0, y, 20, 4) spends column 19 on the frame, so a line drawn
-- to the 160px screen edge prints over its own border.
local CONTENT_RIGHT = 152
local function fits(x) return math.floor((CONTENT_RIGHT - x) / 8) end
-- keep the cursor's box inside the viewport; the fixed bottom row shows
-- the tail of the list
function OptionRows.clampScroll(index, scroll, total, bottomRow)
@@ -27,7 +33,7 @@ function OptionRows.clampScroll(index, scroll, total, bottomRow)
end
-- one bordered box per row, label line + value line, with the fixed
-- bottom line below (CANCEL in the options menu, the manager's footer)
-- bottom line below (BACK in the options menu, the manager's footer)
function OptionRows.draw(game, rows, index, scroll, bottomLabel, bottomRow)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
@@ -37,8 +43,18 @@ function OptionRows.draw(game, rows, index, scroll, bottomLabel, bottomRow)
if not row then break end
Font.drawBox(0, (slot - 1) * 4, 20, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(row.label, 16, ((slot - 1) * 4 + 1) * 8)
Font.draw(row.value and row.value(game) or "", 24, ((slot - 1) * 4 + 2) * 8)
local label = row.label or ""
local value = row.value and row.value(game) or ""
if i == index then
local key = tostring(row.id or row.label) .. "\0" .. tostring(value)
label = Marquee.scroll(label, fits(16), key)
value = Marquee.scroll(value, fits(24), key)
else
label = Marquee.clip(label, fits(16))
value = Marquee.clip(value, fits(24))
end
Font.draw(label, 16, ((slot - 1) * 4 + 1) * 8)
Font.draw(value, 24, ((slot - 1) * 4 + 2) * 8)
if i == index then
Font.drawCode(Theme.cursor, 8, ((slot - 1) * 4 + 1) * 8)
end
+138 -22
View File
@@ -11,10 +11,12 @@
-- bottom line like pokered's.
local PaletteFX = require("src.render.PaletteFX")
local Palette = require("src.render.Palette")
local Pipelines = require("src.render.Pipelines")
local Tilt = require("src.render.Tilt")
local ShaderFX = require("src.render.ShaderFX")
local Zoom = require("src.render.Zoom")
local Letterbox = require("src.render.Letterbox")
local TileRenderer = require("src.render.TileRenderer")
local GameSpeed = require("src.core.GameSpeed")
local GameVersion = require("src.core.GameVersion")
@@ -127,14 +129,6 @@ local function stepVolume(v, dir)
return math.max(0, math.min(7, (v or 7) + dir))
end
local function colorIndex(opts)
local cur = opts.colors or "gbc"
for i, m in ipairs(PaletteFX.MODES) do
if m == cur then return i end
end
return 1
end
local function wrapIndex(i, n)
i = i % n
if i < 0 then i = i + n end
@@ -341,15 +335,14 @@ local function buildRows(game)
end },
{ id = "colors", label = Strings("COLORS"),
value = function(g)
local id = g.save.options.palette
if id and id ~= "" then
return Palette.label(id) or PaletteFX.modeLabel(g.save.options.colors or "gbc")
end
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
end,
step = function(g, dir)
local o = g.save.options
local i = colorIndex(o)
i = wrapIndex(i - 1 + dir, #PaletteFX.MODES) + 1
o.colors = PaletteFX.MODES[i]
PaletteFX.setMode(o.colors)
return true
activate = function(g)
require("src.ui.Screens").push(g, "PaletteScreen")
end },
{ id = "tilt", label = Strings("TILT"),
value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end,
@@ -376,6 +369,16 @@ local function buildRows(game)
-- cycling in place on this row.
-- ShaderFXScreen.lua does the actual list/activate; this row only opens
-- it and shows what is currently active.
{ id = "uiLetterbox", label = Strings("UI LETTERBOX"),
value = function(g)
return Strings(Letterbox.label(g.save.options.uiLetterbox))
end,
step = function(g, dir)
local o = g.save.options
o.uiLetterbox = Letterbox.cycle(o.uiLetterbox, dir)
Letterbox.setMode(o.uiLetterbox)
return true
end },
{ id = "shaderfx", label = Strings("SHADER FX"),
value = function(g)
return shaderfxLabel(ShaderFX.activeEntry("main"))
@@ -643,8 +646,90 @@ local function buildRows(game)
return rows
end
-- Grouping runs AFTER the ui.options.rows hook and never touches self.rows,
-- so a mod still sees and edits the flat list it always did; a row in no
-- group stays where it is, which is where a mod's own additions land.
local GROUPS = {
{ id = "group.battle", label = "BATTLE OPTIONS",
members = { "animations", "battleStyle", "battleLayout", "battleFit",
"battleHud", "battleBg" } },
{ id = "group.audio", label = "AUDIO",
members = { "musicVol", "sfxVol", "pikaVol", "musicFilter" } },
{ id = "group.video", label = "VIDEO",
members = { "uiLayout", "videoMode", "orientation", "faithfulRes",
"screenPos", "fpsCap" } },
{ id = "group.speed", label = "SPEED",
members = { "textSpeed", "speedOverworld", "speedBattle", "speedMenu" } },
{ id = "group.graphics", label = "GRAPHICS",
members = { "colors", "uiLetterbox", "shaderfx", "shaderfx2" } },
-- A mod's Pipelines row splices in after TILT and is in no group, so it
-- stays on the top level rather than being swallowed into this page.
{ id = "group.extras", label = "EXTRAS",
members = { "tilt", "zoom", "voidFill" } },
}
-- The top level's order, groups and singles alike. Anything not named here
-- (a mod's row, the touch rows) keeps its flat position after these.
local ORDER = {
"group.speed", "group.video", "group.graphics", "group.audio",
"performance", "ruleset", "group.battle", "group.extras", "mods",
}
-- Each group replaced by one opener.
local function groupRows(game, rows)
local owner, picked = {}, {}
for _, group in ipairs(GROUPS) do
for _, id in ipairs(group.members) do owner[id] = group end
picked[group.id] = {}
end
for _, row in ipairs(rows) do
local group = row.id and owner[row.id]
if group then picked[group.id][#picked[group.id] + 1] = row end
end
local made = {}
for _, group in ipairs(GROUPS) do
local members = picked[group.id]
if #members > 0 then
made[group.id] = {
id = group.id, label = Strings(group.label), group = true,
value = function() return Strings("%d OPTIONS", #members) end,
-- No onCancel: BACK out of a page returns here, it does not close
-- OPTIONS, so the caller's close callback must not fire.
activate = function(g)
g.stack:push(OptionsMenu.new(g, { rows = members }))
end,
}
end
end
local byId, view, taken = {}, {}, {}
for _, row in ipairs(rows) do
if row.id and not owner[row.id] then byId[row.id] = row end
end
for _, id in ipairs(ORDER) do
local row = made[id] or byId[id]
if row then
view[#view + 1] = row
taken[id] = true
end
end
-- Whatever ORDER does not name, in the order the flat list had it: a mod's
-- own rows, and the platform rows that come and go.
for _, row in ipairs(rows) do
local id = row.id
if not (id and (owner[id] or taken[id])) then view[#view + 1] = row end
end
return view
end
-- opts.rows makes a submenu: a fixed row list, no hook and no regrouping, so
-- a group's page is this same screen driving the rows it was handed.
function OptionsMenu.new(game, opts)
opts = opts or {}
if opts.rows then
return setmetatable({ game = game, rows = opts.rows, view = opts.rows,
index = 1, scroll = 0, sub = true,
onCancel = opts.onCancel }, OptionsMenu)
end
local rows = buildRows(game)
local hooked = Runtime.call("ui.options.rows", sameRows, game, rows)
if type(hooked) == "table" then
@@ -653,14 +738,44 @@ function OptionsMenu.new(game, opts)
Logger.error("ui.options.rows returned %s; keeping the vanilla rows",
type(hooked))
end
return setmetatable({ game = game, rows = rows, index = 1, scroll = 0,
onCancel = opts.onCancel }, OptionsMenu)
local self = setmetatable({ game = game, rows = rows, index = 1, scroll = 0,
onCancel = opts.onCancel }, OptionsMenu)
self.view = groupRows(game, rows)
return self
end
-- Cursor onto a row by id, opening its group page first if it lives in one.
-- Returns the screen the row ended up on, so a caller can keep driving it.
function OptionsMenu:focusRow(id)
local rows = self.view or self.rows
for i, row in ipairs(rows) do
if row.id == id then
self.index = i
self.scroll = OptionRows.clampScroll(i, self.scroll or 0, #rows, #rows + 1)
return self
end
end
for i, row in ipairs(rows) do
if row.group and row.activate then
local group
for _, g in ipairs(GROUPS) do if g.id == row.id then group = g end end
for _, member in ipairs(group and group.members or {}) do
if member == id then
self.index = i
row.activate(self.game)
local sub = self.game.stack:top()
return sub.focusRow and sub:focusRow(id) or sub
end
end
end
end
return nil
end
function OptionsMenu:update(dt)
local input = self.game.input
local rows = self.rows
-- CANCEL sits below the hook-built rows so a mod cannot orphan the exit
local rows = self.view or self.rows
-- BACK sits below the hook-built rows so a mod cannot orphan the exit
local cancelRow = #rows + 1
local changed = false
if input:wasPressed("up") then
@@ -702,14 +817,15 @@ function OptionsMenu:update(dt)
end
function OptionsMenu:draw()
-- Through Strings, like every other label on this menu. CANCEL is
-- Through Strings, like every other label on this menu. BACK is
-- appended AFTER the rows hook (see the header), which is what keeps a mod
-- from orphaning the exit -- but it also means a translation mod never sees
-- this string, and cannot: there is no row for it to rewrite. So the one
-- word a Spanish player could not read on a fully translated OPTIONS menu
-- was the way out of it.
OptionRows.draw(self.game, self.rows, self.index, self.scroll or 0,
Strings("CANCEL"), #self.rows + 1)
local rows = self.view or self.rows
OptionRows.draw(self.game, rows, self.index, self.scroll or 0,
Strings("BACK"), #rows + 1)
end
return OptionsMenu
+578
View File
@@ -0,0 +1,578 @@
local Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local Palette = require("src.render.Palette")
local PaletteFX = require("src.render.PaletteFX")
local PaletteScreen = {}
PaletteScreen.__index = PaletteScreen
PaletteScreen.isOpaque = true
local COLS, ROWS = 5, 4
local TILE_W, TILE_H = 28, 20
local GAP = 4
local GRID_X, GRID_Y = 2, 14
local PER_PAGE = COLS * ROWS
local SCREEN_W, SCREEN_H = 160, 144
local BACK = 0
local function sanitize(text)
text = tostring(text or ""):upper():gsub("_", "'")
text = text:gsub("[^A-Z0-9 %.'%-]", " ")
text = text:gsub("%s+", " "):gsub("^ ", ""):gsub(" $", "")
return text
end
PaletteScreen.sanitize = sanitize -- named for the suite
local function wrap(text, width, lines)
local out = {}
local rest = text
for i = 1, lines do
if rest == "" then break end
if #rest <= width then
out[i] = rest
rest = ""
else
local cut = rest:sub(1, width + 1):match("^.*() ")
if not cut or cut < 2 then cut = width + 1 end
out[i] = rest:sub(1, cut - 1)
rest = rest:sub(cut):gsub("^ +", "")
end
end
if rest ~= "" and #out > 0 then
local last = out[#out]
out[#out] = last:sub(1, math.max(0, width - 1)) .. "."
end
return out
end
PaletteScreen.wrap = wrap -- named for the suite
local function packLabel(name, width)
local text = sanitize(name)
if #text > width then
local leaf = tostring(name):match("([^/]+)$")
if leaf then text = sanitize(leaf) end
end
return wrap(text, width, 1)[1] or ""
end
PaletteScreen.packLabel = packLabel -- named for the suite
local function fill(x, y, w, h, c)
love.graphics.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1)
love.graphics.rectangle("fill", x, y, w, h)
end
local function frame(x, y, w, h, shade)
love.graphics.setColor(shade, shade, shade, 1)
love.graphics.rectangle("line", x + 0.5, y + 0.5, w - 1, h - 1)
end
local function drawSwatch(x, y, w, h, swatch)
if not swatch then
frame(x, y, w, h, 0.5)
return
end
local band = w / 4
for i = 1, 4 do
fill(x + math.floor((i - 1) * band), y,
math.ceil(band), h, swatch[i])
end
end
PaletteScreen.drawSwatch = drawSwatch -- named for the suite
local function drawBack(selected)
if selected then
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", 0, 0, 10, 9)
love.graphics.setColor(1, 1, 1, 1)
else
love.graphics.setColor(0, 0, 0, 1)
end
for i = 0, 3 do
love.graphics.rectangle("fill", 2 + i, 4 - i, 1, 1 + i * 2)
end
end
PaletteScreen.drawBack = drawBack -- named for the suite
local function engineModes()
local labels = PaletteFX.MODE_LABELS or {}
local out = {}
for i, id in ipairs(PaletteFX.MODES) do
out[i] = { PaletteFX.modeLabel(id) or labels[id] or id:upper(), id }
end
return out
end
local function dropConflictingMode(game)
local mode = PaletteFX.mode
local bakes = PaletteFX.usesGbcPack(mode) or PaletteFX.usesSpriteObp(mode)
if not bakes then return end
local o = game.save.options
o.colors = "gbc"
PaletteFX.setMode("gbc")
end
local function liveOpts(game)
dropConflictingMode(game)
return {
palette = Palette,
get = function() return game.save.options.palette or "" end,
set = function(v)
local o = game.save.options
o.palette = v
if v ~= "" then dropConflictingMode(game) end
PaletteFX.setCustomRamp(v ~= "" and Palette.ramp(v) or nil)
if game.writeOptions then pcall(game.writeOptions, game) end
end,
modes = engineModes(),
getMode = function() return game.save.options.colors or "gbc" end,
setMode = function(v)
local o = game.save.options
o.colors = v
PaletteFX.setMode(v)
if game.writeOptions then pcall(game.writeOptions, game) end
end,
}
end
function PaletteScreen.new(game, opts)
opts = opts or liveOpts(game)
local self = setmetatable({
game = game,
Palette = opts.palette,
get = opts.get, set = opts.set,
modes = opts.modes or {},
getMode = opts.getMode, setMode = opts.setMode,
onChange = opts.onChange,
view = "root",
index = 1,
scroll = 0,
group = nil,
stackTrail = {},
}, PaletteScreen)
self.rows = self:buildRoot()
self:enterFromSelection()
return self
end
function PaletteScreen:buildRoot()
local rows = {}
for _, category in ipairs(self.Palette and self.Palette.categories() or {}) do
if #(category.palettes or {}) > 0 then
rows[#rows + 1] = { label = category.name, folder = true,
view = "tiles", group = category }
end
end
if self.Palette and self.Palette.MONO_COLOURS then
rows[#rows + 1] = { label = "MONOCHROME", folder = true, view = "mono" }
end
for _, mode in ipairs(self.modes) do
rows[#rows + 1] = { label = mode[1], mode = mode[2] }
end
return rows
end
function PaletteScreen:rootIndexFor(view, group)
for i, row in ipairs(self.rows or {}) do
if row.view == view then
if view ~= "tiles" then return i end
if row.group and group and row.group.key == group.key then return i end
end
end
return 1
end
function PaletteScreen:enterFromSelection()
local Palette = self.Palette
if not Palette then return end
local id = self.get and self.get() or ""
if type(id) ~= "string" or id == "" then return end
if id:match("^m:") then
for i, c in ipairs(Palette.MONO_COLOURS) do
if Palette.monoId(c[2], c[3], c[4]) == id then
self:openAt("mono", nil, i)
return
end
end
return
end
local key, at = Palette.locate(id)
if key then self:openAt("tiles", Palette.category(key), at) end
end
function PaletteScreen:openAt(view, group, index)
self.stackTrail = { { view = "root", index = self:rootIndexFor(view, group),
scroll = 0, group = nil } }
self.view, self.group, self.index, self.scroll = view, group, index or 1, 0
local items = self:items()
self:clampScroll(#items, #items + 1, PER_PAGE)
end
function PaletteScreen:items()
local Palette = self.Palette
if self.view == "root" then return self.rows or {} end
if self.view == "tiles" then
return self.group and self.group.palettes or {}
end
if self.view == "mono" then
return Palette and Palette.MONO_COLOURS or {}
end
return {}
end
function PaletteScreen:isGrid()
return self.view == "tiles" or self.view == "mono"
end
function PaletteScreen:idAt(i)
local Palette = self.Palette
if not Palette then return nil end
if self.view == "tiles" then
local entry = self.group and (self.group.palettes or {})[i]
return entry and entry.id or nil
elseif self.view == "mono" then
local c = Palette.MONO_COLOURS[i]
return c and Palette.monoId(c[2], c[3], c[4]) or nil
end
return nil
end
function PaletteScreen:title()
if self.view == "root" then return Strings("COLOUR") end
if self.view == "mono" then return Strings("MONOCHROME") end
return sanitize(self.group and self.group.name or "")
end
function PaletteScreen:update()
local input = self.game.input
local items = self:items()
local total = #items
local cancel = total + 1
if input:wasPressed("b") then
self:back()
return
end
if self:isGrid() then
self:moveGrid(input, total, cancel)
else
self:moveList(input, total, cancel)
end
if input:wasPressed("a") then
self:activate(cancel)
end
end
function PaletteScreen:moveList(input, total, cancel)
if self.index == BACK then
if input:wasPressed("down") or input:wasPressed("right") then
self.index = 1
end
return
end
if input:wasPressed("left") then
self.index = BACK
return
end
if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or BACK
elseif input:wasPressed("down") then
self.index = self.index < cancel and self.index + 1 or 1
end
self:clampScroll(total, cancel, 12)
end
function PaletteScreen:moveGrid(input, total, cancel)
local i = self.index
if i == BACK then
if input:wasPressed("down") or input:wasPressed("right") then
self.index = self.scroll + 1
if self.index > total then self.index = 1 end
end
return
end
if i > total then
if input:wasPressed("up") then
self.index = total
elseif input:wasPressed("down") then
self.index = 1
end
self:clampScroll(total, cancel, PER_PAGE)
return
end
if input:wasPressed("left") then
self.index = ((i - 1) % COLS == 0) and BACK or i - 1
elseif input:wasPressed("right") then
self.index = i < total and i + 1 or 1
elseif input:wasPressed("up") then
self.index = (i - COLS >= 1) and i - COLS or BACK
elseif input:wasPressed("down") then
if i + COLS <= total then
self.index = i + COLS
else
self.index = cancel
end
end
self:clampScroll(total, cancel, PER_PAGE)
end
function PaletteScreen:clampScroll(total, cancel, visible)
if self.index == BACK then return end
if self.index >= cancel then
self.scroll = math.max(0, total - visible)
if self:isGrid() then
self.scroll = math.floor(self.scroll / COLS) * COLS
end
return
end
local step = self:isGrid() and COLS or 1
if self.index <= self.scroll then
self.scroll = math.floor((self.index - 1) / step) * step
elseif self.index > self.scroll + visible then
self.scroll = math.floor((self.index - 1) / step) * step - visible + step
end
if self.scroll < 0 then self.scroll = 0 end
end
function PaletteScreen:activate(cancel)
if self.index == BACK or self.index >= cancel then
self:back()
return
end
if self.view == "root" then
local row = (self.rows or {})[self.index]
if not row then return end
if row.folder then
self:push(row.view, row.group)
elseif row.mode then
if self.set then self.set("") end
if self.setMode then self.setMode(row.mode) end
self:changed()
end
elseif self:isGrid() then
local id = self:idAt(self.index)
if id then
if self.set then self.set(id) end
self:changed()
end
end
end
function PaletteScreen:changed()
if self.game.data and Sound and Sound.play then
pcall(Sound.play, self.game.data, "Press_AB")
end
if self.onChange then self.onChange() end
end
function PaletteScreen:push(view, group)
self.stackTrail[#self.stackTrail + 1] =
{ view = self.view, index = self.index, scroll = self.scroll,
group = self.group }
self.view, self.index, self.scroll = view, 1, 0
self.group = group
self:cursorToSelection()
end
function PaletteScreen:cursorToSelection()
local id = self.get and self.get() or ""
if type(id) ~= "string" or id == "" then return end
local items = self:items()
for i = 1, #items do
if self:idAt(i) == id then
self.index = i
self:clampScroll(#items, #items + 1,
self:isGrid() and PER_PAGE or 12)
return
end
end
end
function PaletteScreen:back()
local prev = table.remove(self.stackTrail)
if prev then
self.view, self.index = prev.view, prev.index
self.scroll, self.group = prev.scroll, prev.group
return
end
self:close()
end
function PaletteScreen:close()
if self.game.data and Sound and Sound.play then
pcall(Sound.play, self.game.data, "Press_AB")
end
self.game.stack:pop()
end
function PaletteScreen:sgbPalettes()
return nil
end
function PaletteScreen:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H)
drawBack(self.index == BACK)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self:title(), 12, 0)
if self:isGrid() then
self:drawGrid()
else
self:drawList()
end
love.graphics.setColor(0, 0, 0, 1)
local items = self:items()
if self.index >= #items + 1 then
Font.draw(Strings("CANCEL"), 16, SCREEN_H - 8)
Font.drawCode(Theme.cursor, 8, SCREEN_H - 8)
end
love.graphics.setColor(1, 1, 1, 1)
end
function PaletteScreen:drawList()
local items = self:items()
local mode = self.getMode and self.getMode() or nil
local visible = 12
for slot = 1, visible do
local i = self.scroll + slot
local row = items[i]
if not row then break end
local y = 16 + (slot - 1) * 8
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings(row.label or ""), 16, y)
if i == self.index then
Font.drawCode(Theme.cursor, 8, y)
end
if row.folder then
Font.drawCode(Theme.cursor, 8 + 8 * 17, y)
elseif row.mode and mode and row.mode == mode then
love.graphics.rectangle("fill", 8 + 8 * 17 + 2, y + 2, 4, 4)
end
end
end
function PaletteScreen:drawGrid()
local Palette = self.Palette
local items = self:items()
local total = #items
for slot = 1, PER_PAGE do
local i = self.scroll + slot
if i > total then break end
local col = (slot - 1) % COLS
local row = math.floor((slot - 1) / COLS)
local x = GRID_X + col * (TILE_W + GAP)
local y = GRID_Y + row * (TILE_H + GAP)
local swatch
if self.view == "mono" then
local c = Palette and Palette.MONO_COLOURS[i]
if c then
local ramp = Palette.monoRamp(c[2], c[3], c[4])
swatch = { ramp[4], ramp[3], ramp[2], ramp[1] }
end
else
swatch = Palette and Palette.swatch(self:idAt(i)) or nil
end
drawSwatch(x, y, TILE_W, TILE_H, swatch)
if i == self.index then
frame(x - 2, y - 2, TILE_W + 4, TILE_H + 4, 0)
frame(x - 1, y - 1, TILE_W + 2, TILE_H + 2, 1)
end
end
self:drawFooter(total)
end
function PaletteScreen:drawFooter(total)
local Palette = self.Palette
love.graphics.setColor(0, 0, 0, 1)
local name, pack = "", nil
if self.index >= 1 and self.index <= total then
if self.view == "mono" then
local c = Palette and Palette.MONO_COLOURS[self.index]
name = c and c[1] or ""
else
local entry = self.group and (self.group.palettes or {})[self.index]
if entry then
name = sanitize(entry.name)
pack = entry.pack
end
end
end
local lines = wrap(name, 20, 2)
for i = 1, #lines do
Font.draw(lines[i], 0, 110 + (i - 1) * 8)
end
local at = 0
if total > PER_PAGE then
local page = math.floor(self.scroll / PER_PAGE) + 1
local pages = math.ceil(total / PER_PAGE)
Font.draw(("%d/%d"):format(page, pages), 0, 128)
at = 40
end
if pack then
Font.draw(packLabel(pack, (SCREEN_W - at) / 8), at, 128)
end
local stored = self.get and self.get() or ""
if type(stored) == "string" and stored ~= "" then
for slot = 1, PER_PAGE do
local i = self.scroll + slot
if i > total then break end
if self:idAt(i) == stored then
local col = (slot - 1) % COLS
local row = math.floor((slot - 1) / COLS)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle(
"fill",
GRID_X + col * (TILE_W + GAP) + TILE_W - 5,
GRID_Y + row * (TILE_H + GAP) + TILE_H - 5, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle(
"fill",
GRID_X + col * (TILE_W + GAP) + TILE_W - 4,
GRID_Y + row * (TILE_H + GAP) + TILE_H - 4, 2, 2)
break
end
end
end
end
return PaletteScreen
+21 -5
View File
@@ -71,6 +71,12 @@ function PartyMenu:sgbPalettes(game)
return zones
end
-- data/moves/field_moves.asm: leftmost tile per field move name
local FIELD_MOVE_X = {
cut = 12, fly = 12, surf = 12, flash = 12, DIG = 12,
strength = 10, TELEPORT = 10, softboiled = 8,
}
local function sameItems(_, items) return items end
local function followerUnavailable(game, mon)
@@ -706,6 +712,9 @@ function PartyMenu:update(dt)
-- (text_box.asm .donePrintingNames). #768
items[#items + 1] = { label = Strings("STATS"), action = "stats" }
items[#items + 1] = { label = Strings("SWITCH"), action = "switch" }
-- CANCEL closes the whole party menu (start_sub_menus.asm:71-75
-- .exitMenu), where B returns to the party list. #1833
items[#items + 1] = { label = Strings("CANCEL"), action = "cancel" }
end
local ctx = { battle = self.battle, overworld = ow }
local hooked = Runtime.call("ui.party.submenu", sameItems,
@@ -873,12 +882,19 @@ function PartyMenu:draw()
end
if self.submenu then
local n = #self.subItems
Font.drawBox(9, 17 - n * 2 - 1, 11, n * 2 + 1)
local y0 = (17 - n * 2) * 8
for si, entry in ipairs(self.subItems) do
Font.draw(entry.label, 88, y0 + (si - 1) * 16)
-- engine/menus/text_box.asm:397-440, data/text_boxes.asm:33 #1819
local lx = 12
for _, entry in ipairs(self.subItems) do
local mx = FIELD_MOVE_X[entry.move or entry.action]
if mx and mx < lx then lx = mx end
end
Font.drawCode(Theme.cursor, 80, y0 + (self.subIndex - 1) * 16)
local top = n > 3 and math.max(0, 16 - n * 2) or 11
Font.drawBox(lx - 1, top, 21 - lx, 18 - top)
local y0 = (18 - n * 2) * 8
for si, entry in ipairs(self.subItems) do
Font.draw(entry.label, (lx + 1) * 8, y0 + (si - 1) * 16)
end
Font.drawCode(Theme.cursor, lx * 8, y0 + (self.subIndex - 1) * 16)
end
love.graphics.setColor(1, 1, 1, 1)
end
+46 -3
View File
@@ -11,6 +11,8 @@ local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local TextBox = require("src.render.TextBox")
local romText = require("src.core.RomText")
local PlayerPC = {}
@@ -36,9 +38,20 @@ local function buildItems(game, store, order)
})
end
end
-- the $ff terminator's row (home/list_menu.asm:371-372, 523-528)
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
return items
end
-- A on CANCEL leaves the list exactly like B (home/list_menu.asm:105-110)
local function leftOnCancel(item, list)
if item and item.cancel then
list:close()
return true
end
return false
end
-- Ask "How many?" (DepositHowManyText/WithdrawHowManyText →
-- DisplayChooseQuantityMenu) capped at the stack count. Key items and
-- HMs always move one, with no prompt (IsKeyItem in players_pc.asm).
@@ -76,11 +89,21 @@ end
local function withdraw(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
-- players_pc.asm:144-149: an empty list is never opened
if next(pc) == nil then
game.stack:push(TextBox.new(game, romText(game.data, "_NothingStoredText",
"There is nothing\nstored."), nil, { noSound = true }))
return
end
game.stack:push(ListMenu.new(game, nil, buildItems(game, pc), {
kind = "pc_item_withdraw",
messageBox = true,
-- players_pc.asm:151-152 WhatToWithdrawText, printed before the list
footer = romText(game.data, "_WhatToWithdrawText",
"What do you want\nto withdraw?"),
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
if leftOnCancel(item, list) then return end
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag")
if not Bag.add(game.save, item.value, qty, game.data) then
@@ -112,11 +135,21 @@ local function deposit(game)
local Bag = require("src.inventory.Bag")
-- engine/menus/players_pc.asm:99 wListPointer = wNumBagItems, so deposit order == bag order
local order = Bag.order(game.save, game.data)
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, inv, order), {
-- players_pc.asm:90-95: an empty bag never reaches the list
if #order == 0 then
game.stack:push(TextBox.new(game, romText(game.data, "_NothingToDepositText",
"You have nothing\nto deposit."), nil, { noSound = true }))
return
end
game.stack:push(ListMenu.new(game, nil, buildItems(game, inv, order), {
kind = "pc_item_deposit",
messageBox = true,
-- players_pc.asm:97-98 WhatToDepositText, printed before the list
footer = romText(game.data, "_WhatToDepositText",
"What do you want\nto deposit?"),
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
if leftOnCancel(item, list) then return end
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
if pcFull(game, pc, item.value) then
list.footer = Strings("No room left to\nstore items.")
@@ -134,11 +167,21 @@ end
local function toss(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
-- players_pc.asm:196-201: an empty list is never opened
if next(pc) == nil then
game.stack:push(TextBox.new(game, romText(game.data, "_NothingStoredText",
"There is nothing\nstored."), nil, { noSound = true }))
return
end
game.stack:push(ListMenu.new(game, nil, buildItems(game, pc), {
kind = "pc_item_toss",
messageBox = true,
-- players_pc.asm:205-206 WhatToTossText, printed before the list
footer = romText(game.data, "_WhatToTossText",
"What do you want\nto toss away?"),
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
if leftOnCancel(item, list) then return end
local def = game.data.items[item.value]
if (def and def.keyItem) or item.value:find("^HM_") then
list.footer = Strings("That's too impor-\ntant to toss!")
+273 -82
View File
@@ -1,9 +1,38 @@
-- Minimal Pokédex: dex-ordered list with seen/owned markers.
-- Pokédex CONTENTS screen: engine/menus/pokedex.asm:157-348
-- (Yellow: pokeyellow engine/menus/pokedex.asm:263-336).
local ListMenu = require("src.ui.ListMenu")
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Theme = require("src.ui.Theme")
local PokedexMenu = {}
PokedexMenu.__index = PokedexMenu
PokedexMenu.isOpaque = true
-- engine/menus/pokedex.asm:226 (`ld d, 7`), :21 (top menu item Y 3, X 0)
local ROWS = 7
local FIRST_ROW_Y = 24
local NUM_X, BALL_X, NAME_X, CURSOR_X = 8, 24, 32, 0
-- engine/menus/pokedex.asm:163-199
-- (Yellow: pokeyellow engine/menus/pokedex.asm:266-306)
local RED_ROWS = { seen = 2, own = 5, rule = 8, items = 10 }
local YELLOW_ROWS = { seen = 1, own = 4, rule = 6, items = 8 }
-- engine/menus/pokedex.asm:350 (DrawPokedexVerticalLine): column 14, $71
-- toggling to $70 down each nine-tile run, both runs restarting on $71
local function dividerCodes()
local codes = { [0] = 0x71 }
for _, top in ipairs({ 1, 9 }) do
local code = 0x71
for i = 0, 8 do
codes[top + i] = code
code = code == 0x71 and 0x70 or 0x71
end
end
return codes
end
local DIVIDER = dividerCodes()
-- SGB: PalPacket_Pokedex, whole screen
function PokedexMenu:sgbPalettes(game)
@@ -12,108 +41,270 @@ end
function PokedexMenu.new(game, opts)
opts = opts or {}
local self = setmetatable({}, PokedexMenu)
self.game = game
self.onCancel = opts.onCancel -- B returns to the start menu when opened from it
self.index = 1
self.scroll = 0
self.rowsAt = require("src.core.GameVersion").isYellow()
and YELLOW_ROWS or RED_ROWS
local dex = game.save.pokedex or { seen = {}, owned = {} }
local byDex = {}
for species, def in pairs(game.data.pokemon) do
if def.dex then byDex[def.dex] = def end
end
local items = {}
local seen, owned = 0, 0
-- dex bound and number width come from constants; the fallbacks keep a
-- cache imported before those keys existed on the Kanto numbering
local constants = game.data.constants or {}
local numFmt = ("%%0%dd"):format(constants.dexDigits or 3)
for n = 1, constants.dexSize or 151 do
local dexSize = constants.dexSize or 151
-- engine/menus/pokedex.asm:200-216: the list stops at wDexMaxSeenMon, the
-- highest number the player has seen, not at the end of the roster
local maxSeen = 0
local seen, owned = 0, 0
for n = 1, dexSize do
local def = byDex[n]
if def then
local label
if dex.owned[def.id] then
label = (numFmt .. " %s"):format(n, def.name)
owned = owned + 1
seen = seen + 1
maxSeen = n
elseif dex.seen[def.id] then
label = (numFmt .. " %s"):format(n, def.name)
seen = seen + 1
else
label = (numFmt .. " -----"):format(n)
maxSeen = n
end
table.insert(items, {
label = label,
end
end
local items = {}
for n = 1, math.min(dexSize, maxSeen) do
local def = byDex[n]
if def then
local known = dex.owned[def.id] or dex.seen[def.id]
-- engine/menus/pokedex.asm:265 (.dashedLine) for anything unseen
local name = known and def.name or "----------"
items[#items + 1] = {
num = numFmt:format(n),
name = name,
label = (numFmt .. " %s"):format(n, name),
-- owned entries carry the pokéball marker like the original
-- list; seen-only entries are just the name
ball = dex.owned[def.id] or nil,
value = (dex.owned[def.id] or dex.seen[def.id]) and def.id or nil,
})
value = known and def.id or nil,
}
end
end
local list = ListMenu.new(game, "POKéDEX", items, {
-- SEEN / OWN in the original's fixed three-digit field: engine/menus/
-- pokedex.asm HandlePokedexListMenu prints both counts with
-- `lb bc, 1, 3` (hlcoord 16,3 and 16,6), labelled by PokedexSeenText
-- and PokedexOwnText ("OWN", not "OWNED"). The width is load bearing
-- here: a bare ListMenu footer goes through the 18-column text wrap,
-- so the old 19-glyph "SEEN 100 OWNED 100" split in two and its first
-- half landed on the list's last row at y=120 (#639).
footer = Strings("SEEN %3d OWN %3d", seen, owned),
pageJump = true, -- Left/Right page jumps like the original
onCancel = opts.onCancel, -- B returns to the start menu when opened from it
onChoose = function(item, dexList)
if not item.value then return end
-- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm
-- PokedexMenuItemsText); CRY keeps the side menu open like the
-- original. B is what returns to the list: HandlePokedexSideMenu
-- hands back b=2 for B and b=1 for QUIT, and ShowPokedexMenu sends
-- b=1 to .exitPokedex, so QUIT closes the whole Pokédex (#571)
local Menu = require("src.ui.Menu")
local Screens = require("src.ui.Screens")
local entries = {
{ label = Strings("DATA"), onSelect = function()
Screens.push(game, "DexEntryMenu", item.value)
end },
{ label = Strings("CRY"), keepOpen = true, onSelect = function()
require("src.core.Sound").playCry(game.data, item.value)
end },
{ label = Strings("AREA"), onSelect = function()
Screens.push(game, "TownMap", { nestSpecies = item.value })
end },
}
-- Yellow's PRNT item (engine/menus/pokedex.asm PokedexMenuItemsText
-- _YELLOW branch -> PrintPokedexEntry): the Game Boy Printer job is
-- stood in for by a PNG of the entry page saved under prints/.
if require("src.core.GameVersion").isYellow() then
entries[#entries + 1] = { label = Strings("PRNT"), onSelect = function()
local DexEntryMenu = require("src.ui.DexEntryMenu")
local Printer = require("src.core.Printer")
local TextBox = require("src.render.TextBox")
local def = game.data.pokemon[item.value]
local path = require("src.pokemon.Sprites").path(
game.data, item.value, "front", { kind = "dex" })
local ok, sprite = false, nil
if path then ok, sprite = pcall(love.graphics.newImage, path) end
local saved, err = Printer.save("dex_" .. item.value, 160, 144,
function()
DexEntryMenu.render(game, def, ok and sprite or nil, false)
end)
game.stack:push(TextBox.new(game, saved
and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.",
def.name, saved)
or Strings("Printer error!\n%s", tostring(err))))
end }
end
entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function()
-- .exitPokedex: drop the dex list too and hand back to whoever
-- opened it (the start menu, whose saved cursor is still on
-- POKéDEX -- wBattleAndStartSavedMenuItem)
dexList:close()
if opts.onCancel then opts.onCancel() end
end }
game.stack:push(Menu.new(game, entries,
{ tx = 12, ty = 8, tw = 8,
th = #entries * 2 + 2 }))
end,
})
list.sgbPalettes = PokedexMenu.sgbPalettes
return list
self.items = items
-- SEEN / OWN, `lb bc, 1, 3` at hlcoord 16,3 and 16,6: engine/menus/
-- pokedex.asm HandlePokedexListMenu (#639)
self.seenCount, self.ownedCount = seen, owned
self.footer = Strings("SEEN %3d OWN %3d", seen, owned)
return self
end
-- HandleMenuInput_ replays SFX_PRESS_AB for A and B (home/window.asm)
local function beep(self)
if not (self.game and self.game.data) then return end
require("src.core.Sound").play(self.game.data, "Press_AB")
end
function PokedexMenu:rows()
return math.min(ROWS, #self.items)
end
-- engine/menus/pokedex.asm:290-313
function PokedexMenu:syncScroll()
local rows = self:rows()
if rows == 0 then
self.index, self.scroll = 1, 0
return
end
self.index = math.max(1, math.min(#self.items, self.index))
if self.index - self.scroll > rows then self.scroll = self.index - rows end
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
end
-- engine/menus/pokedex.asm:314-342: Left and Right move wListScrollOffset
-- by seven and never touch wCurrentMenuItem, so the cursor keeps its row
function PokedexMenu:pageScroll(dir)
local n = #self.items
if n < ROWS then return end
local row = self.index - self.scroll
local scroll = self.scroll + dir * ROWS
scroll = math.max(0, math.min(n - ROWS, scroll))
self.scroll = scroll
self.index = math.min(n, scroll + row)
end
function PokedexMenu:close()
local top = self.game.stack:top()
if top == self then self.game.stack:pop() end
end
function PokedexMenu:update(dt)
-- back on top of the stack, so the side menu's hollow '▷' is gone
-- (engine/menus/pokedex.asm:61, PlaceUnfilledArrowMenuCursor)
self.hollowIndex = nil
local input = self.game.input
if #self.items == 0 then
if input:wasPressed("a") or input:wasPressed("b") then
beep(self)
self.game.stack:pop()
if self.onCancel then self.onCancel() end
end
return
end
if input:wasPressed("up") then
self.index = self.index - 1
elseif input:wasPressed("down") then
self.index = self.index + 1
elseif input:wasPressed("left") then
self:pageScroll(-1)
elseif input:wasPressed("right") then
self:pageScroll(1)
elseif input:wasPressed("b") then
beep(self)
self.game.stack:pop()
if self.onCancel then self.onCancel() end
return
elseif input:wasPressed("a") then
beep(self)
self.onChoose(self.items[self.index], self)
return
end
self:syncScroll()
end
-- engine/menus/pokedex.asm:371-375, :81-85
function PokedexMenu:sideItems()
local labels = { Strings("DATA"), Strings("CRY"), Strings("AREA") }
if require("src.core.GameVersion").isYellow() then
labels[#labels + 1] = Strings("PRNT")
end
labels[#labels + 1] = Strings("QUIT")
return labels
end
local function sideRowY(menu, row)
return (menu.ty + menu.th - 2 - (#menu.items - row) * 2) * 8
end
local function drawSideMenu(self)
love.graphics.setColor(0, 0, 0, 1)
for row = 1, #self.items do
Font.draw(self.items[row].label, (self.tx + 2) * 8, sideRowY(self, row))
end
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, sideRowY(self, self.index))
love.graphics.setColor(1, 1, 1, 1)
end
-- engine/menus/pokedex.asm HandlePokedexSideMenu, ShowPokedexMenu
-- .exitPokedex (#571)
local function chooseEntry(item, dexList)
local game = dexList.game
-- engine/menus/pokedex.asm:77-80: an unseen row hands back b=2 at once
if not item.value then return end
local Menu = require("src.ui.Menu")
local Screens = require("src.ui.Screens")
local entries = {
{ label = Strings("DATA"), onSelect = function()
Screens.push(game, "DexEntryMenu", item.value)
end },
{ label = Strings("CRY"), keepOpen = true, onSelect = function()
require("src.core.Sound").playCry(game.data, item.value)
end },
{ label = Strings("AREA"), onSelect = function()
Screens.push(game, "TownMap", { nestSpecies = item.value })
end },
}
-- pokeyellow engine/menus/pokedex.asm PokedexMenuItemsText,
-- PrintPokedexEntry
if require("src.core.GameVersion").isYellow() then
entries[#entries + 1] = { label = Strings("PRNT"), onSelect = function()
local DexEntryMenu = require("src.ui.DexEntryMenu")
local Printer = require("src.core.Printer")
local TextBox = require("src.render.TextBox")
local def = game.data.pokemon[item.value]
local path = require("src.pokemon.Sprites").path(
game.data, item.value, "front", { kind = "dex" })
local ok, sprite = false, nil
if path then ok, sprite = pcall(love.graphics.newImage, path) end
local saved, err = Printer.save("dex_" .. item.value, 160, 144,
function()
DexEntryMenu.render(game, def, ok and sprite or nil, false)
end)
game.stack:push(TextBox.new(game, saved
and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.",
def.name, saved)
or Strings("Printer error!\n%s", tostring(err))))
end }
end
entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function()
-- engine/menus/pokedex.asm .exitPokedex
dexList:close()
if dexList.onCancel then dexList.onCancel() end
end }
local ty = dexList.rowsAt.items - 2
local side = Menu.new(game, entries, { tx = 14, ty = ty, tw = 6 })
-- the block is already on screen, so the side menu is the cursor alone:
-- text at column 16, cursor at 15 (engine/menus/pokedex.asm:82-86)
side.tx, side.tw, side.ty, side.th = 14, 6, ty, #entries * 2 + 2
side.draw = drawSideMenu
dexList.hollowIndex = dexList.index
game.stack:push(side)
end
PokedexMenu.onChoose = chooseEntry
-- engine/menus/pokedex.asm:256 writes tile $72 in the column left of the
-- name; the port paints the same marker rather than carry a one-tile sheet
local function drawBall(x, y)
love.graphics.circle("fill", x + 4, y + 4, 3.5)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", x + 0.5, y + 3.5, 7, 1)
love.graphics.circle("fill", x + 4, y + 4, 1.2)
love.graphics.setColor(0, 0, 0, 1)
end
function PokedexMenu:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local at = self.rowsAt
local DexEntryMenu = require("src.ui.DexEntryMenu")
-- engine/menus/pokedex.asm:159-176: the divider column and the rule that
-- fences SEEN / OWN off from the side-menu block
for ty = 0, 17 do
DexEntryMenu.tile(self.game, DIVIDER[ty], 14, ty)
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw((""):rep(5), 120, at.rule * 8)
Font.draw(Strings("CONTENTS"), 8, 8)
Font.draw(Strings("SEEN"), 128, at.seen * 8)
Font.draw(Strings("OWN"), 128, at.own * 8)
-- PrintNumber's fixed three-digit field ends on column 19
local function count(n, ty)
local text = tostring(n)
Font.draw(text, 152 - Font.width(text), ty * 8)
end
count(self.seenCount, at.seen + 1)
count(self.ownedCount, at.own + 1)
local labels = self:sideItems()
for i, label in ipairs(labels) do
Font.draw(label, 128, (at.items + (i - 1) * 2) * 8)
end
for row = 1, self:rows() do
local i = self.scroll + row
local item = self.items[i]
if not item then break end
local y = FIRST_ROW_Y + (row - 1) * 16
-- the number sits one row above its name (engine/menus/pokedex.asm:242-246)
Font.draw(item.num, NUM_X, y - 8)
if item.ball then drawBall(BALL_X, y) end
Font.draw(item.name, NAME_X, y)
if i == self.index then
Font.drawCode(self.hollowIndex == i
and Theme.cursorHollow or Theme.cursor, CURSOR_X, y)
end
end
love.graphics.setColor(1, 1, 1, 1)
end
return PokedexMenu
+14 -23
View File
@@ -192,9 +192,9 @@ function SlotMachine.new(game, lucky)
local self = setmetatable({}, SlotMachine)
self.game = game
self.wheels = game.data.field.slotWheels
-- intro | bet | spinup | spin | reroll | flash | message | payout | onemore
-- PromptUserToPlaySlots asks "Want to play?" before the session starts.
self.stage = "intro"
-- bet | spinup | spin | reroll | flash | message | payout | onemore
-- PromptUserToPlaySlots: engine/slots/slot_machine.asm:9-23
self.stage = "bet"
self.yesno = 1 -- YES/NO cursor (1 = YES); wCurrentMenuItem
-- CoinMultiplierSlotMachineText lists ×3/×2/×1 with the cursor defaulting to
-- the top (wCurrentMenuItem 0), i.e. bet = 3 - menuItem.
@@ -417,7 +417,7 @@ function SlotMachine:startPayout()
self.flash = false
end
-- YES/NO prompt shared by the intro ("Want to play?") and "One more go?".
-- the "One more go?" YES/NO prompt.
function SlotMachine:updateYesNo(onYes)
local input = self.game.input
if input:wasPressed("up") or input:wasPressed("down") then
@@ -435,12 +435,6 @@ function SlotMachine:update(dt)
local input = self.game.input
local save = self.game.save
if self.stage == "intro" then
-- PromptUserToPlaySlots: "A slot machine! Want to play?"
self:updateYesNo(function() self:enterBet() end)
return
end
if self.stage == "message" then
if self.exitTimer then
-- OutOfCoinsSlotMachineText: DelayFrames 60, then leave
@@ -638,9 +632,7 @@ end
-- menu on the right (like MainSlotMachineLoop's TextBoxBorder + menus).
function SlotMachine:drawBottom()
local lines
if self.stage == "intro" then
lines = { "A slot machine!", "Want to play?" }
elseif self.stage == "bet" then
if self.stage == "bet" then
lines = { "Bet how many", "coins?" }
elseif self.stage == "onemore" then
lines = { "One more", "go?" }
@@ -661,22 +653,21 @@ function SlotMachine:drawBottom()
Font.draw(lines[1] or "", 8, 14 * 8)
Font.draw(lines[2] or "", 8, 16 * 8)
if self.stage == "bet" then
Font.drawBox(14, 11, 6, 5)
-- hlcoord 14,11 with b=5 c=4 -- engine/slots/slot_machine.asm:83-86
Font.drawBox(14, 11, 6, 7)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("×3", 16 * 8, 12 * 8)
Font.draw("×2", 16 * 8, 13 * 8)
Font.draw("×1", 16 * 8, 14 * 8)
Font.drawCode(0xED, 15 * 8, (12 + self.betIndex) * 8)
elseif self.stage == "intro" or self.stage == "onemore" then
-- "One more go?" sits at the right of the box (hlcoord 14,12); the longer
-- "A slot machine!" prompt would clip against it, so the intro's YES/NO
-- floats above the reels instead.
local by = self.stage == "intro" and 6 or 11
Font.drawBox(13, by, 6, 5)
elseif self.stage == "onemore" then
-- hlcoord 14,12 with the YES_NO_MENU 4x3 body
-- engine/slots/slot_machine.asm:136-138, data/yes_no_menu_strings.asm:10
Font.drawBox(14, 12, 6, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("YES"), 15 * 8, (by + 1) * 8)
Font.draw(Strings("NO"), 15 * 8, (by + 2) * 8)
Font.drawCode(0xED, 14 * 8, (by + 1 + (self.yesno == 1 and 0 or 1)) * 8)
Font.draw(Strings("YES"), 16 * 8, 13 * 8)
Font.draw(Strings("NO"), 16 * 8, 14 * 8)
Font.drawCode(0xED, 15 * 8, (13 + (self.yesno == 1 and 0 or 1)) * 8)
end
love.graphics.setColor(1, 1, 1, 1)
end
+2 -2
View File
@@ -200,12 +200,12 @@ function StartMenu.new(game)
anchor = "topright" })
-- the cursor position survives closing the menu
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
menu.index = math.min(game.save.startMenuIndex or 1, #items)
menu.index = math.min(game.startMenuIndex or 1, #items)
menu:clampScroll()
local baseUpdate = menu.update
menu.update = function(self, dt)
baseUpdate(self, dt)
game.save.startMenuIndex = self.index
game.startMenuIndex = self.index
end
-- inside the Safari Zone the start menu also shows remaining steps and
+43 -27
View File
@@ -130,6 +130,25 @@ local function markerXY(loc)
return loc.x * 8 + 16, loc.y * 8 + 8
end
-- the marker wears its own sheet's OBJ palette and shade-0 keying
-- engine/items/town_map.asm:342
local function markerSheet(def, seed)
local colors, group
if PaletteFX.usesGbcPack() then
colors, group = PaletteFX.spriteObp(def, seed)
end
if not colors then
if PaletteFX.usesSpriteObp() then
colors, group = PaletteFX.ogObj()
else
colors, group = PaletteFX.dmgObj()
end
end
local ok, img = pcall(SpriteRenderer.obpImage, def and def.image, colors, group)
if not (ok and img) then return nil, nil end
return img, love.graphics.newQuad(0, 0, 16, 16, img:getDimensions())
end
-- the row-0 name banner; fly mode prefixes "To " like LoadTownMap_Fly
-- (engine/menus/town_map.asm prints the destination as "To <NAME>")
function TownMap:bannerText(loc)
@@ -224,32 +243,18 @@ function TownMap.new(game, opts)
local mapId = game.overworld and game.overworld.map and game.overworld.map.id
self.playerLoc = mapId and self.byMap[mapId] or nil
-- engine/items/town_map.asm:347
do
local playerSprites = (game.data.field and game.data.field.playerSprites)
or {}
local sprites = game.data.sprites or {}
local red = sprites[playerSprites.walk or "SPRITE_RED"]
or sprites.SPRITE_RED
-- the marker is the overworld walking sheet, so it wears that sheet's OBJ
-- palette and shade-0 keying -- engine/items/town_map.asm:342
local colors, group
if PaletteFX.usesGbcPack() then
colors, group = PaletteFX.spriteObp(red, "player")
end
if not colors then
if PaletteFX.usesSpriteObp() then
colors, group = PaletteFX.ogObj()
else
colors, group = PaletteFX.dmgObj()
end
end
local ok, img = pcall(SpriteRenderer.obpImage,
red and red.image, colors, group)
if ok and img then
self.playerSheet = img
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
img:getDimensions())
end
local playerSprites = (game.data.field and game.data.field.playerSprites)
or {}
local sprites = game.data.sprites or {}
self.playerSheet, self.playerQuad =
markerSheet(sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED,
"player")
-- LoadTownMap_Fly overwrites the cursor tiles with BirdSprite and marks
-- the destination with it -- engine/items/town_map.asm:146-149, 177-179
if self.fly then
self.birdSheet, self.birdQuad =
markerSheet(sprites[playerSprites.fly or "SPRITE_BIRD"]
or sprites.SPRITE_BIRD, "bird")
end
self.sel = 1
-- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST
@@ -402,8 +407,19 @@ function TownMap:draw()
end
end
-- WriteTownMapSpriteOAM carry quirk: -4 X, -3 Y for cursor and player alike -- engine/items/town_map.asm:454
-- LoadTownMap_Fly's .inputLoop has no blinking animation
-- engine/items/town_map.asm:190-198
local showCursor = true
if GameVersion.generation() == 1 then
if self.fly and self.birdSheet then
if selected then
local x, y = markerXY(selected)
love.graphics.draw(self.birdSheet, self.birdQuad, x - 4, y - 3)
if PaletteFX.usesSpriteObp() or PaletteFX.usesGbcPack() then
PaletteFX.markUiSpriteRedraw(self.birdSheet, self.birdQuad, x - 4, y - 3)
end
end
showCursor = false
elseif GameVersion.generation() == 1 then
showCursor = self.blink < 25
else
showCursor = self.blink % 16 < 10
+2 -5
View File
@@ -18,6 +18,7 @@
local bit = require("bit")
local Assets = require("src.render.Assets")
local Chrome = require("src.ui.gen2.Chrome")
local GbcPalette = require("src.render.GbcPalette")
local Palettes = require("src.world.gen2.Palettes")
@@ -164,12 +165,8 @@ local function needsCanvas(runner)
end
-- The battle background is BG colour 0 everywhere the two pic boxes and the
-- text box are not, which Chrome.clear draws as plain white; anything the
-- scanline blit exposes has to be that colour and not a hole.
function BattleAnimView:fillBackground()
local G = love.graphics
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H)
Chrome.paletteFill(0, 0, SCREEN_W, SCREEN_H)
end
-- One reusable quad, re-aimed per scanline. A row shifted by `dx` is drawn
+91 -35
View File
@@ -605,23 +605,66 @@ function BattleState:animData(mon)
local def = self.pokemon and mon and self.pokemon[mon.species]
if not def then return nil end
if mon.species == Unown.SPECIES and def.letters then
local entry = def.letters[Unown.monLetter(mon)]
local entry = def.letters[Unown.name(Unown.monLetter(mon))]
if entry and entry.anim then return entry.anim end
end
return def.anim
end
-- ../pokecrystal/engine/gfx/load_pics.asm:105-131
function BattleState:frontPicReplaced(mon)
local def = self.pokemon and mon and self.pokemon[mon.species]
local vanilla = def and def.spriteFront
if mon and mon.species == Unown.SPECIES then
vanilla = Unown.formSprite(self.pokemon, Unown.monLetter(mon), false)
or vanilla
end
if type(vanilla) ~= "string" then return false end
local _, _, path = self:pic(mon, false)
if type(path) == "string" and path ~= vanilla then return true end
return Assets.resolve(vanilla) ~= vanilla
end
-- one cart asset, two files on the pokemon.sprite seam (#1827)
-- ../pokecrystal/engine/gfx/load_pics.asm:132-158
function BattleState:animSheetPath(mon, data)
local path = data and data.sheet
if type(path) ~= "string" then return nil, false end
if not Runtime.wantsHook("pokemon.sprite") then
return path, Assets.resolve(path) ~= path
end
local letter
if mon and mon.species == Unown.SPECIES then letter = Unown.monLetter(mon) end
local hooked = Runtime.call("pokemon.sprite",
function(value) return value end, path, {
species = mon and mon.species,
side = "front",
kind = "battle_anim",
mon = mon,
data = (self.game and self.game.data) or nil,
letter = letter,
shiny = mon and mon.shiny and true or false,
})
if type(hooked) == "string" and hooked ~= "" and hooked ~= path then
return hooked, true
end
return path, Assets.resolve(path) ~= path
end
-- ANIM_MON_NORMAL, the scene BattleStartMessage runs on the enemy's frontpic.
-- ../pokecrystal/engine/battle/core.asm:9112-9113
function BattleState:startFrontAnim(mon)
self.frontAnim = nil
local data = self:animData(mon)
if not data then return end
local cached = self.picCache[data.sheet]
local sheet, sheetReplaced = self:animSheetPath(mon, data)
if not sheet then return end
if not sheetReplaced and self:frontPicReplaced(mon) then return end
local cached = self.picCache[sheet]
if cached == nil then
local ok, image = pcall(Assets.image, data.sheet)
local ok, image = pcall(Assets.image, sheet)
cached = ok and image or false
self.picCache[data.sheet] = cached
self.picCache[sheet] = cached
end
if not cached then return end
local runner = MonAnim.new(data, "battle")
@@ -3520,7 +3563,7 @@ function BattleState:drawHud()
-- And nothing at all before UpdateEnemyHUD has ever run: the intro bands
-- slide in over a blanked tilemap (core.asm:8554/8564).
if showStatus and self.showEnemyHud and not self:hudCleared("enemy") then
Chrome.print(self:name(enemy), 1, 0)
Chrome.printThrough(self:name(enemy), 1, 0, Chrome.DEFAULT_BOX_PALETTE)
-- PrintLevel writes <LV> at the coordinate it is given and then LEFT-aligns
-- the digits after it, so the glyph is pinned to column 6 whether the level
-- is 5 or 100; only a three-digit level moves, and it does so by eating the
@@ -3528,10 +3571,12 @@ function BattleState:drawHud()
-- PlaceNonFaintStatus (engine/pokemon/mon_stats.asm): a statused mon's tag
-- prints where the level goes, and DrawEnemyHUD's `.skip_level` arm drops
-- the level entirely while one is up.
Chrome.print(self:statusTag(enemy, "enemy")
or ("<LV>" .. tostring(enemy.level or 1)), 6, 1)
Chrome.printThrough(self:statusTag(enemy, "enemy")
or ("<LV>" .. tostring(enemy.level or 1)), 6, 1, Chrome.DEFAULT_BOX_PALETTE)
local enemyGender = self:genderSymbol(enemy)
if enemyGender then Chrome.print(enemyGender, 9, 1) end
if enemyGender then
Chrome.printThrough(enemyGender, 9, 1, Chrome.DEFAULT_BOX_PALETTE)
end
-- `ld a, [wBattleMode] / dec a / ret nz`, then CheckCaughtMon puts $5d at
-- (1,1) (engine/battle/trainer_huds.asm:140-152).
if self.battle and self.battle.wild and self.caughtMark then
@@ -3580,17 +3625,20 @@ function BattleState:drawHud()
Font.useBattleExtra(wasBattle)
return
end
Chrome.print(self:name(player), 10, 7)
Chrome.printThrough(self:name(player), 10, 7, Chrome.DEFAULT_BOX_PALETTE)
-- PrintPlayerHUD places the same status tag at (14,8) and skips the level
-- while it is up.
Chrome.print(self:statusTag(player, "player")
or ("<LV>" .. tostring(self.shownLevel or player.level or 1)), 14, 8)
Chrome.printThrough(self:statusTag(player, "player")
or ("<LV>" .. tostring(self.shownLevel or player.level or 1)), 14, 8,
Chrome.DEFAULT_BOX_PALETTE)
local playerGender = self:genderSymbol(player)
if playerGender then Chrome.print(playerGender, 17, 8) end
if playerGender then
Chrome.printThrough(playerGender, 17, 8, Chrome.DEFAULT_BOX_PALETTE)
end
self:drawHpBar(player, "player", 10, 9)
local maxHp = player.maxHp or (player.stats and player.stats.hp) or 0
Chrome.printRight(("%d/%d"):format(self:hudHp(player, "player"), maxHp),
18, 10)
Chrome.printRightThrough(("%d/%d"):format(self:hudHp(player, "player"), maxHp),
18, 10, Chrome.DEFAULT_BOX_PALETTE)
-- Stub on the RIGHT (tile $73), border from (18,10) laid leftward, exp bar
-- at (10,11).
-- The chased fill, not the mon's: AnimateExpBar crawls it, and reading the
@@ -3659,8 +3707,8 @@ end
function BattleState:printMessage()
local lines = Chrome.wrap(self.message or "", TEXT_WIDTH)
for i = 1, math.min(#lines, TEXT_ROWS) do
Chrome.print(lines[i], TEXT_INNER_X,
TEXT_INNER_Y + (i - 1) * TEXT_ROW_STEP)
Chrome.printThrough(lines[i], TEXT_INNER_X,
TEXT_INNER_Y + (i - 1) * TEXT_ROW_STEP, Chrome.DEFAULT_BOX_PALETTE)
end
end
@@ -3670,15 +3718,17 @@ function BattleState:drawMoveInfoBox(move)
if not move then return end
local fighter = self.battle and self.battle.player
if fighter and self.battle:moveDisabled(fighter, move.id) then
Chrome.print("Disabled!", 1, 10)
Chrome.printThrough("Disabled!", 1, 10, Chrome.DEFAULT_BOX_PALETTE)
return
end
local def = self.game and self.game.data and self.game.data.moves
and self.game.data.moves[move.id]
Chrome.print("TYPE/", 1, 9)
Chrome.printThrough("TYPE/", 1, 9, Chrome.DEFAULT_BOX_PALETTE)
local moveType = def and def.type
Chrome.print(moveType and (TYPE_NAMES[moveType] or moveType) or "", 2, 10)
Chrome.print(("%2d/%2d"):format(move.pp or 0, move.maxPp or 0), 5, 11)
Chrome.printThrough(moveType and (TYPE_NAMES[moveType] or moveType) or "",
2, 10, Chrome.DEFAULT_BOX_PALETTE)
Chrome.printThrough(("%2d/%2d"):format(move.pp or 0, move.maxPp or 0),
5, 11, Chrome.DEFAULT_BOX_PALETTE)
end
function BattleState:drawPanel()
@@ -3687,7 +3737,7 @@ function BattleState:drawPanel()
-- required there; everywhere else a missing side is a caller bug.
local hasPlayer = self.battle and (self.battle.player or self.tutorial)
if not (self.battle and hasPlayer and self.battle.enemy) then
Chrome.print("NO BATTLE", 1, 1)
Chrome.printThrough("NO BATTLE", 1, 1, Chrome.DEFAULT_BOX_PALETTE)
return
end
self:drawHud()
@@ -3719,8 +3769,10 @@ function BattleState:drawPanel()
local col = ((i - 1) % 2) * spacing
local row = math.floor((i - 1) / 2) * 2
local tx, ty = boxX + 2 + col, 14 + row
if i == self.menuIndex then Chrome.cursor(tx - 1, ty) end
Chrome.print(label, tx, ty)
if i == self.menuIndex then
Chrome.cursorThrough(tx - 1, ty, Chrome.DEFAULT_BOX_PALETTE)
end
Chrome.printThrough(label, tx, ty, Chrome.DEFAULT_BOX_PALETTE)
end
elseif self.phase == "moves"
or (self.phase == "choose-forget" and (self.messageTimer or 0) <= 0) then
@@ -3740,21 +3792,24 @@ function BattleState:drawPanel()
for i, move in ipairs(moves) do
local ty = 13 + (i - 1)
-- Cursor in the box's own gutter, not clipped against the border.
if i == cursorRow then Chrome.cursor(cursorCol, ty) end
if i == cursorRow then
Chrome.cursorThrough(cursorCol, ty, Chrome.DEFAULT_BOX_PALETTE)
end
-- The held slot's marker. `.battle_player_moves` writes '▷' into the
-- row wSwappingMove names (engine/battle/core.asm:5157-5165) so a move
-- picked up for a swap is visible while the cursor moves off it. It
-- hlcoord 5, 13 is the cursor's own gutter, so PlaceMenuCursor covers
-- the marker on the cursor's row.
if not forgetting and self.moveSwapIndex == i and i ~= cursorRow then
Chrome.print("\u{25B7}", cursorCol, ty)
Chrome.printThrough("\u{25B7}", cursorCol, ty, Chrome.DEFAULT_BOX_PALETTE)
end
local def = self.game and self.game.data and self.game.data.moves
and self.game.data.moves[move.id]
Chrome.print((def and def.name) or move.id, nameCol, ty)
Chrome.printThrough((def and def.name) or move.id, nameCol, ty,
Chrome.DEFAULT_BOX_PALETTE)
if not moveMenu then
Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0),
19, ty)
Chrome.printRightThrough(("%d/%d"):format(move.pp or 0, move.maxPp or 0),
19, ty, Chrome.DEFAULT_BOX_PALETTE)
end
end
if moveMenu then self:drawMoveInfoBox(moves[cursorRow]) end
@@ -3773,13 +3828,14 @@ function BattleState:drawPanel()
local left = (self.phase == "ask-shift" or self.phase == "ask-next-mon")
and 1 or 14
Chrome.box(left, 7, 6, 5)
Chrome.print("YES", left + 2, 8)
Chrome.print("NO", left + 2, 10)
Chrome.printThrough("YES", left + 2, 8, Chrome.DEFAULT_BOX_PALETTE)
Chrome.printThrough("NO", left + 2, 10, Chrome.DEFAULT_BOX_PALETTE)
local index = self.phase == "ask-nickname" and self.nicknameIndex
or self.phase == "ask-shift" and self.shiftIndex
or self.phase == "ask-next-mon" and self.nextMonIndex
or self.forgetChoice
Chrome.cursor(left + 1, index == 1 and 8 or 10)
Chrome.cursorThrough(left + 1, index == 1 and 8 or 10,
Chrome.DEFAULT_BOX_PALETTE)
end
end
if self.phase == "stats-box" and self.statsBoxMon then
@@ -3808,8 +3864,9 @@ function BattleState:drawStatsBox(mon)
Chrome.textbox(9, 0, 9, 10)
for i, row in ipairs(STATS_BOX_ROWS) do
local ty = 1 + (i - 1) * 2
Chrome.print(Strings(row[1]), 11, ty)
Chrome.printRight(("%d"):format(stats[row[2]] or 0), 19, ty + 1)
Chrome.printThrough(Strings(row[1]), 11, ty, Chrome.DEFAULT_BOX_PALETTE)
Chrome.printRightThrough(("%d"):format(stats[row[2]] or 0), 19, ty + 1,
Chrome.DEFAULT_BOX_PALETTE)
end
end
@@ -3893,8 +3950,7 @@ end
function BattleState:drawWidescreen(winW, winH)
local G = love.graphics
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
Chrome.letterbox(winW, winH, 1, 1, 1)
local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push()
+1 -2
View File
@@ -945,8 +945,7 @@ end
function BoxMenu:drawWidescreen(winW, winH)
local G = love.graphics
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
Chrome.letterbox(winW, winH, 1, 1, 1)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -968,8 +968,7 @@ end
function CardFlip:drawWidescreen(winW, winH)
local G = love.graphics
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
Chrome.letterbox(winW, winH, 1, 1, 1)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -309,8 +309,7 @@ end
function CenterPcMenu:drawWidescreen(winW, winH)
local G = love.graphics
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
Chrome.letterbox(winW, winH, 1, 1, 1)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
+156 -41
View File
@@ -27,13 +27,39 @@ Chrome.SCREEN_H = 18
-- charmap.asm "¥", the money field's own prefix tile.
local YEN = "\xc2\xa5"
function Chrome.clear()
function Chrome.paletteFill(px, py, pw, ph, palette)
palette = palette or Chrome.DEFAULT_BOX_PALETTE
local G = love.graphics
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
if GbcPalette.available() then
GbcPalette.with(palette, function() G.rectangle("fill", px, py, pw, ph) end)
else
G.rectangle("fill", px, py, pw, ph)
end
G.setColor(0, 0, 0, 1)
end
-- Every drawWidescreen paints the window around its 160x144 panel through
-- here, so UI LETTERBOX has exactly one place to override. r/g/b is what the
-- screen was authored with and is what AUTO keeps.
local function letterboxPaper()
local c = GbcPalette.resolve(Chrome.DEFAULT_BOX_PALETTE)
c = c and c[1]
if not c then return nil end
return c[1] / 255, c[2] / 255, c[3] / 255
end
function Chrome.letterbox(winW, winH, r, g, b)
local Letterbox = require("src.render.Letterbox")
local G = love.graphics
G.setColor(Letterbox.fill(r or 1, g or 1, b or 1, letterboxPaper))
G.rectangle("fill", 0, 0, winW, winH)
end
function Chrome.clear()
Chrome.paletteFill(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
end
-- The blit scale every `drawWidescreen` paints its 160x144 panel at.
--
-- One GB pixel has to cover a WHOLE number of window pixels or the 8x8 grid
@@ -77,10 +103,24 @@ function Chrome.positionLift(winW, winH, scale)
or Chrome.fitScale(winW, winH)), ScreenPosition.safeTop())
end
Chrome.DEFAULT_BOX_PALETTE = {
{ 255, 255, 255 }, { 255, 255, 255 }, { 255, 255, 255 }, { 0, 0, 0 },
}
function Chrome.paletteBox(tx, ty, tw, th, palette)
palette = palette or Chrome.DEFAULT_BOX_PALETTE
if GbcPalette.available() then
love.graphics.setColor(1, 1, 1, 1)
GbcPalette.with(palette, function() Font.drawBox(tx, ty, tw, th) end)
else
Font.drawBox(tx, ty, tw, th, palette[1])
end
love.graphics.setColor(0, 0, 0, 1)
end
-- A bordered box, tile coords. Leaves the draw color black for text.
function Chrome.box(tx, ty, tw, th)
Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1)
Chrome.paletteBox(tx, ty, tw, th)
end
-- Gold's Textbox helper takes an interior width/height and draws the border
@@ -90,11 +130,16 @@ function Chrome.textbox(tx, ty, interiorW, interiorH)
Chrome.box(tx, ty, interiorW + 2, interiorH + 2)
end
function Chrome.print(text, tx, ty)
local function flatPrint(text, tx, ty)
love.graphics.setColor(0, 0, 0, 1)
return Font.draw(text, tx * 8, ty * 8)
end
function Chrome.print(text, tx, ty)
if not GbcPalette.available() then return flatPrint(text, tx, ty) end
return Chrome.printThrough(text, tx, ty, Chrome.DEFAULT_BOX_PALETTE)
end
-- Prints a string the way a tilemap screen does: the glyph tiles replace the
-- cells they land on, and both their ink and their blank pixels go through
-- that screen's own BG palette.
@@ -124,32 +169,39 @@ function Chrome.throughPalette(palette, invert)
return GbcPalette.remap(pal, GbcPalette.bgp)
end
-- `palette` is a 4-colour table of 0-255 triples. `invert` is what
-- Chrome.printInverted passes; see there.
function Chrome.printThrough(text, tx, ty, palette, invert)
function Chrome.rawPalette(palette, invert)
if invert then return { palette[4], palette[3], palette[2], palette[1] } end
return palette
end
-- The per-glyph shader/tint switch printThrough needs, factored out so a
-- caller that positions its own glyphs (TextBox's typewriter/scroll
-- animation) gets the same shaded ink without re-deriving the TTF-vs-tile
-- split.
--
-- Returns (pal, drawGlyph, finish):
-- pal the resolved ramp, or nil if there's no shader to draw
-- through; a caller that also draws a paper rect should skip
-- it and take the plain Chrome.print degrade instead.
-- drawGlyph (code, x, y): draws one glyph, shaded through `pal` for a
-- tile glyph or tinted with pal[4] for a TTF one (see the
-- inline comment below, gen1recomp#1642).
-- finish call once after the last glyph to restore the previous
-- shader and draw colour.
function Chrome.paletteGlyphs(palette, invert, raw)
if not (palette and GbcPalette.available()) then
return Chrome.print(text, tx, ty)
return nil, Font.drawCode, function() end
end
-- `pal` is used below and never touches the caller's palette again --
-- including useRaw for the draw, since useRaw skips the fold GbcPalette.use
-- would otherwise apply and folding it a second time would undo this.
local pal = Chrome.throughPalette(palette, invert)
local width = Font.width(text)
local paper = pal[1] or { 255, 255, 255 }
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8)
-- Per glyph, not per string: a TTF-mod build still keeps the multi-byte
-- charmap sequences (the naming screen's <PK>/<MN>, the 'd/'l/'s ligatures)
-- and anything the mod names in ttf.tiles (Font.lua's own note on keeping
-- digits tile-based for column alignment) on their ROM tiles, so a string
-- can freely mix the two kinds of glyph.
local pal = raw and Chrome.rawPalette(palette, invert)
or Chrome.throughPalette(palette, invert)
local ink = pal[4] or { 0, 0, 0 }
local previous = love.graphics.getShader()
local shaded = false
local pen = tx * 8
for _, code in ipairs(Font.encode(text)) do
local function drawGlyph(code, x, y)
if code >= Font.TTF_BASE then
-- The shader below recovers a shade from the RED CHANNEL of an already
-- flat-shaded 2bpp tile sheet -- exactly what a TTF glyph is not.
-- LÖVE's font rasterizer stores glyph coverage as alpha over a plain
-- white texture, which this shader reads back as shade 0 no matter how
@@ -167,11 +219,28 @@ function Chrome.printThrough(text, tx, ty, palette, invert)
GbcPalette.useRaw(pal)
shaded = true
end
Font.drawCode(code, pen, ty * 8)
Font.drawCode(code, x, y)
end
local function finish()
if shaded then love.graphics.setShader(previous) end
love.graphics.setColor(0, 0, 0, 1)
end
return pal, drawGlyph, finish
end
function Chrome.printThrough(text, tx, ty, palette, invert, raw)
local pal, drawGlyph, finish = Chrome.paletteGlyphs(palette, invert, raw)
if not pal then return Chrome.print(text, tx, ty) end
local width = Font.width(text)
local paper = pal[1] or { 255, 255, 255 }
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8)
local pen = tx * 8
for _, code in ipairs(Font.encode(text)) do
drawGlyph(code, pen, ty * 8)
pen = pen + Font.advanceOf(code)
end
if shaded then love.graphics.setShader(previous) end
love.graphics.setColor(0, 0, 0, 1)
finish()
return width
end
@@ -186,12 +255,34 @@ end
-- Right-aligned within a field that ends at tile `txEnd` (exclusive), which is
-- how Gold prints numbers (PrintNum fills from the right).
function Chrome.printRight(text, txEnd, ty)
local function flatPrintRight(text, txEnd, ty)
local width = Font.width(text)
love.graphics.setColor(0, 0, 0, 1)
return Font.draw(text, txEnd * 8 - width, ty * 8)
end
function Chrome.printRight(text, txEnd, ty)
if not GbcPalette.available() then return flatPrintRight(text, txEnd, ty) end
return Chrome.printRightThrough(text, txEnd, ty, Chrome.DEFAULT_BOX_PALETTE)
end
function Chrome.printRightThrough(text, txEnd, ty, palette, invert, raw)
local pal, drawGlyph, finish = Chrome.paletteGlyphs(palette, invert, raw)
if not pal then return Chrome.printRight(text, txEnd, ty) end
local width = Font.width(text)
local tx = txEnd * 8 - width
local paper = pal[1] or { 255, 255, 255 }
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
love.graphics.rectangle("fill", tx, ty * 8, width, 8)
local pen = tx
for _, code in ipairs(Font.encode(text)) do
drawGlyph(code, pen, ty * 8)
pen = pen + Font.advanceOf(code)
end
finish()
return width
end
-- Wrap text to `width` tiles, measuring with the real font so a proportional
-- page wraps where it actually overflows. Nothing on a 160px screen may print
-- past tile 20: text that does is drawn outside the GB frame entirely.
@@ -227,21 +318,27 @@ function Chrome.printWrapped(text, tx, ty, width, rows)
return #lines
end
function Chrome.cursor(tx, ty, hollow)
local function flatCursor(tx, ty, hollow)
love.graphics.setColor(0, 0, 0, 1)
Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8)
end
function Chrome.cursor(tx, ty, hollow)
if not GbcPalette.available() then return flatCursor(tx, ty, hollow) end
return Chrome.cursorThrough(tx, ty, Chrome.DEFAULT_BOX_PALETTE, nil, hollow)
end
-- The cursor glyph through a palette, the way Chrome.printThrough draws text.
-- A screen whose font page is inverted (the #DEX: Pokedex_LoadInvertedFont
-- xors both bitplanes, so it runs white on black) needs its cursor inverted
-- with everything else -- Chrome.cursor's flat black is invisible against that
-- ground, which is what made the dex's action arrow look absent.
function Chrome.cursorThrough(tx, ty, palette, invert, hollow)
function Chrome.cursorThrough(tx, ty, palette, invert, hollow, raw)
if not (palette and GbcPalette.available()) then
return Chrome.cursor(tx, ty, hollow)
end
local pal = Chrome.throughPalette(palette, invert)
local pal = raw and Chrome.rawPalette(palette, invert)
or Chrome.throughPalette(palette, invert)
local paper = pal[1] or { 255, 255, 255 }
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
love.graphics.rectangle("fill", tx * 8, ty * 8, 8, 8)
@@ -286,8 +383,8 @@ end
-- PRINTNUM_LEADINGZEROS.
function Chrome.coinBalanceBox(coins)
Chrome.textbox(11, 0, 7, 1)
Chrome.print("COIN", 12, 0)
Chrome.print(Chrome.number(coins, 4, true), 13, 1)
Chrome.printThrough("COIN", 12, 0, Chrome.DEFAULT_BOX_PALETTE)
Chrome.printThrough(Chrome.number(coins, 4, true), 13, 1, Chrome.DEFAULT_BOX_PALETTE)
end
-- DisplayMoneyAndCoinBalance: one Textbox at (5,0) with a 13x3 interior
@@ -295,10 +392,10 @@ end
-- "COIN" at (6,3) with the four-digit count at (15,3).
function Chrome.moneyAndCoinBalanceBox(money, coins)
Chrome.textbox(5, 0, 13, 3)
Chrome.print("MONEY", 6, 1)
Chrome.print(Chrome.money(money), 12, 1)
Chrome.print("COIN", 6, 3)
Chrome.print(Chrome.number(coins, 4, true), 15, 3)
Chrome.printThrough("MONEY", 6, 1, Chrome.DEFAULT_BOX_PALETTE)
Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE)
Chrome.printThrough("COIN", 6, 3, Chrome.DEFAULT_BOX_PALETTE)
Chrome.printThrough(Chrome.number(coins, 4, true), 15, 3, Chrome.DEFAULT_BOX_PALETTE)
end
-- PlaceMoneyTopRight: MoneyTopRightMenuHeader is `menu_coords 11, 0,
@@ -306,7 +403,7 @@ end
-- prints the number at MenuBoxCoord2Tile + SCREEN_WIDTH + 1, i.e. (12,1).
function Chrome.moneyBalanceBox(money)
Chrome.box(11, 0, 9, 3)
Chrome.print(Chrome.money(money), 12, 1)
Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE)
end
-- A vertical cursor list.
@@ -344,6 +441,7 @@ function List.new(opts)
self.onChoose = opts.onChoose
self.onCancel = opts.onCancel
self.onMove = opts.onMove
self.palette = opts.palette == nil and Chrome.DEFAULT_BOX_PALETTE or opts.palette
self.index = math.max(1, math.min(opts.index or 1, math.max(1, #self.items)))
self.scroll = 0
self:ensureVisible()
@@ -409,15 +507,32 @@ function List:draw()
local item = self.items[i]
if item then
local ty = self.y + (row - 1) * self.spacing
if i == self.index then Chrome.cursor(self.x - 1, ty) end
Chrome.print(item.label, self.x, ty)
if i == self.index then
if self.palette then
Chrome.cursorThrough(self.x - 1, ty, self.palette)
else
flatCursor(self.x - 1, ty)
end
end
if self.palette then
Chrome.printThrough(item.label, self.x, ty, self.palette)
else
flatPrint(item.label, self.x, ty)
end
end
end
-- Scrolling lists get the ▼ hint Gold shows when there is more below.
if self.rows < #self.items and self.scroll + self.rows < #self.items then
love.graphics.setColor(0, 0, 0, 1)
Font.drawCode(Chrome.DOWN_ARROW, (self.x - 1) * 8,
(self.y + self.rows * self.spacing - 1) * 8)
if self.palette then
local _, drawGlyph, finish = Chrome.paletteGlyphs(self.palette)
drawGlyph(Chrome.DOWN_ARROW, (self.x - 1) * 8,
(self.y + self.rows * self.spacing - 1) * 8)
finish()
else
love.graphics.setColor(0, 0, 0, 1)
Font.drawCode(Chrome.DOWN_ARROW, (self.x - 1) * 8,
(self.y + self.rows * self.spacing - 1) * 8)
end
end
end
+1 -2
View File
@@ -176,8 +176,7 @@ end
function ContestMenu:drawWidescreen(winW, winH)
local G = love.graphics
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
Chrome.letterbox(winW, winH, 1, 1, 1)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))

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