Compare commits

..

22 Commits

Author SHA1 Message Date
bryanthaboi e24f812475 Merge pull request #1553 from bryanthaboi/dev
fix stuff baby
2026-08-19 06:10:44 -04:00
github-actions f8ba51636b chore(ios): update app-repo.json [skip ci] 2026-08-18 17:18:36 -04:00
bryanthaboi fb97318e87 Merge pull request #1541 from bryanthaboi/dev
tuesday afternoon squashing
2026-08-18 17:08:46 -04:00
github-actions fc83ecd52f chore(ios): update app-repo.json [skip ci] 2026-08-18 12:05:11 -04:00
bryanthaboi 5ba49bdf3f Merge pull request #1524 from bryanthaboi/dev
squishing some fun ones
2026-08-18 11:55:28 -04:00
github-actions c01bda3570 chore(ios): update app-repo.json [skip ci] 2026-08-18 10:28:41 -04:00
bryanthaboi 74e04cb086 Merge pull request #1523 from bryanthaboi/dev
some bugs and some switch stuff
2026-08-18 10:18:58 -04:00
github-actions 0ea224d5db chore(ios): update app-repo.json [skip ci] 2026-08-17 23:13:43 -04:00
bryanthaboi c2e1db0f89 Merge pull request #1501 from bryanthaboi/dev 2026-08-17 23:03:49 -04:00
github-actions 4c13770e70 chore(ios): update app-repo.json [skip ci] 2026-08-17 20:51:52 -04:00
bryanthaboi cf45cbbf92 Merge pull request #1498 from bryanthaboi/dev
more stuff
2026-08-17 20:41:58 -04:00
github-actions 7d1ddf9b7c chore(ios): update app-repo.json [skip ci] 2026-08-17 20:09:51 -04:00
bryanthaboi faf82c2cec Merge pull request #1491 from AverageConsumer/codex/gen2-move-grid-hook
fix(gen2): honor mod move-grid navigation
2026-08-17 19:58:35 -04:00
AverageConsumer e0e030003b fix(gen2): honor mod move-grid navigation 2026-08-17 20:38:48 +02:00
github-actions ce2afb83f1 chore(ios): update app-repo.json [skip ci] 2026-08-17 14:13:48 -04:00
bryanthaboi 28f741f72f Merge pull request #1489 from bryanthaboi/dev
Update LauncherView.lua
2026-08-17 14:04:35 -04:00
github-actions ea28f886f3 chore(ios): update app-repo.json [skip ci] 2026-08-17 13:20:07 -04:00
bryanthaboi 6cd8f0ddea Merge pull request #1487 from bryanthaboi/dev
[release 0.2.0]
2026-08-17 13:10:08 -04:00
github-actions fb4eaeda10 chore(ios): update app-repo.json [skip ci] 2026-08-16 22:37:21 -04:00
bryanthaboi 69100301a1 Merge pull request #1460 from bryanthaboi/dev 2026-08-16 22:28:20 -04:00
github-actions 114352b75f chore(ios): update app-repo.json [skip ci] 2026-08-16 10:23:08 -04:00
bryanthaboi 0e40a7a1f4 Merge pull request #1408 from bryanthaboi/dev 2026-08-16 10:14:26 -04:00
83 changed files with 481 additions and 2672 deletions
-1
View File
@@ -1 +0,0 @@
* @bryanthaboi
-58
View File
@@ -26,64 +26,6 @@ permissions:
contents: read
jobs:
macos-changes:
name: detect macOS changes
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.paths.outputs.changed }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: paths
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.sha }}
run: |
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(conf\.lua$|scripts/(build\.sh|build_love_macos\.sh|run\.sh|setup\.sh)$|Play-Mac\.command$|mobile/macos/|src/core/SaveData\.lua$|\.github/workflows/(ci|release)\.yml$)'; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
macos-build:
name: macOS LÖVE 12 build
needs: macos-changes
if: needs.macos-changes.outputs.changed == 'true'
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
steps:
- uses: actions/checkout@v7
- name: Build LÖVE 12 macOS runtime
run: scripts/build_love_macos.sh --fetch
- name: Build and verify macOS app
env:
LOVE_APP: ${{ github.workspace }}/.bazinga/love12/love.app
MAC_STAGE_DIR: ${{ runner.temp }}/gen1recomp-mac-stage
run: |
set -euo pipefail
scripts/build.sh mac --no-notarize --identity - --version 0.0.0
unzip -tqq 'dist/mac/gen1recomp++-macos.zip'
app="$MAC_STAGE_DIR/gen1recomp++.app"
[ -d "$app" ]
[ -x "$app/Contents/MacOS/gen1recomp++" ]
[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app/Contents/Info.plist")" = 'gen1recomp++' ]
[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleDisplayName' "$app/Contents/Info.plist")" = 'gen1recomp++' ]
[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$app/Contents/Info.plist")" = 'com.theboisclub.gen1recompplusplus' ]
[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist")" = 0.0.0 ]
codesign --verify --deep --strict "$app"
otool -L "$app/Contents/Frameworks/love.framework/love" | grep -q '/Metal.framework/'
- name: Upload macOS build
uses: actions/upload-artifact@v7
with:
name: gen1recomp++-macos
path: dist/mac/gen1recomp++-macos.zip
if-no-files-found: error
retention-days: 7
ios-changes:
name: detect iOS changes
runs-on: ubuntu-latest
+3 -10
View File
@@ -344,16 +344,9 @@ jobs:
echo "Identities available to codesign:"
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
- name: Build LÖVE 12 macOS runtime
run: |
set -euo pipefail
scripts/build_love_macos.sh --fetch
- name: Build macOS + Windows + Linux
env:
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
LOVE_APP: ${{ github.workspace }}/.bazinga/love12/love.app
MAC_STAGE_DIR: ${{ runner.temp }}/gen1recomp-mac-stage
run: |
set -euo pipefail
# Sign in-build (identity auto-detected from the temp keychain);
@@ -435,8 +428,8 @@ jobs:
set -a; . "$ci_dir/notary.env"; set +a
echo "::add-mask::$APPLE_APP_PASSWORD"
app="$RUNNER_TEMP/gen1recomp-mac-stage/gen1recomp++.app"
zip="dist/mac/gen1recomp++-macos.zip"
app=".bazinga/work/gen1recomp.app"
zip="dist/mac/gen1recomp-macos.zip"
[ -d "$app" ] || { echo "::error::signed app not found at $app"; exit 1; }
if [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_APP_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then
echo "::error::notary.env is missing APPLE_ID / APPLE_APP_PASSWORD / APPLE_TEAM_ID."
@@ -481,7 +474,7 @@ jobs:
outdir="dist/release"
rm -rf "$outdir"
mkdir -p "$outdir"
cp "dist/mac/gen1recomp++-macos.zip" "$outdir/gen1recomp++-${v}-macos.zip"
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
+18 -13
View File
@@ -27,15 +27,9 @@ ask() { # ask "question" -> yes by default
printf '\n \033[1mPokémon Red - LÖVE2D port\033[0m\n\n'
have_love() {
local app version
for app in ".bazinga/love12/love.app" "/Applications/love12.app" "$HOME/Applications/love12.app" \
"/Applications/love.app" "$HOME/Applications/love.app"; do
[ -x "$app/Contents/MacOS/love" ] || continue
version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist" 2>/dev/null || true)"
printf '%s' "$version" | grep -Eq '^12(\.|$)' || continue
otool -L "$app/Contents/Frameworks/love.framework/love" 2>/dev/null \
| grep -q '/Metal.framework/' && return 0
done
command -v love >/dev/null 2>&1 && return 0
[ -x "/Applications/love.app/Contents/MacOS/love" ] && return 0
[ -x "$HOME/Applications/love.app/Contents/MacOS/love" ] && return 0
return 1
}
@@ -63,11 +57,22 @@ if ! command -v python3 >/dev/null 2>&1; then
fi
fi
if ! have_love && ! command -v xcodebuild >/dev/null 2>&1; then
warn "LÖVE 12 is not installed and Xcode is required to build it for macOS."
warn "Install Xcode from the App Store, launch it once, then run this again."
pause_exit 1
# ----------------------------------------------------------------- Homebrew
if ! have_love && ! command -v brew >/dev/null 2>&1 \
&& [ ! -x /opt/homebrew/bin/brew ] && [ ! -x /usr/local/bin/brew ]; then
warn "LÖVE (the game engine) is not installed; the easiest installer is Homebrew"
if ask "Install Homebrew now? (asks for your macOS password)"; then
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" \
|| { err "Homebrew install failed"; pause_exit 1; }
else
warn "OK, download LÖVE 11.x yourself from https://love2d.org,"
warn "drop love.app into /Applications, then run this again."
pause_exit 1
fi
fi
# make brew visible in THIS shell (fresh installs aren't on PATH yet)
[ -x /opt/homebrew/bin/brew ] && eval "$(/opt/homebrew/bin/brew shellenv)"
[ -x /usr/local/bin/brew ] && eval "$(/usr/local/bin/brew shellenv)"
# ------------------------------------------------------------------- build
echo
+3 -4
View File
@@ -53,7 +53,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
### Watch the latest update video
[![Watch the latest update video](https://img.youtube.com/vi/yi7LkWQPKKM/maxresdefault.jpg)](https://youtu.be/yi7LkWQPKKM)
[![Watch the latest update video](https://img.youtube.com/vi/8IOgqbe4YvA/maxresdefault.jpg)](https://www.youtube.com/watch?v=8IOgqbe4YvA)
This project does not include a ROM, emulate the Game Boy, transpile assembly,
@@ -194,9 +194,8 @@ By default the game keeps your save, options, and the private ROM-derived
data cache in your OS's normal per-user app data folder. To keep everything
next to the game instead (handy for a USB stick or portable drive you carry
between computers), drop an empty file named `portable.txt` next to the app
(next to `gen1recomp++.app` on macOS or `gen1recomp.exe` on Windows, or next
to `main.lua`/`conf.lua` when running from source), then launch the game.
Portable mode is desktop-only
(next to `gen1recomp.app`/`.exe`, or next to `main.lua`/`conf.lua` when
running from source), then launch the game. Portable mode is desktop-only
(Windows, Linux, macOS); it has no effect on Android or iOS, where the app
runs from a read-only package.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

+1 -1
View File
@@ -61,7 +61,7 @@ function love.conf(t)
t.window.minwidth = 480
t.window.minheight = 360
end
t.version = (love._os == "iOS" or love._os == "OS X") and "12.0" or "11.5"
t.version = love._os == "iOS" and "12.0" or "11.5"
t.window.vsync = 1
t.modules.audio = not companion
t.modules.joystick = not companion
-19
View File
@@ -5,27 +5,8 @@
-- voucher exchange and the BICYCLE/CANCEL price window need more than
-- command rows (#568).
local TextBox = require("src.render.TextBox")
-- data/events/hidden_events.asm:542
local BIKE_DISPLAYS = {
{ 1, 0 }, { 2, 1 }, { 1, 2 }, { 3, 2 }, { 0, 4 }, { 1, 5 },
}
return {
BIKE_SHOP = {
-- engine/events/hidden_events/new_bike.asm:1
onInteract = function(game, ow, fx, fy)
for _, c in ipairs(BIKE_DISPLAYS) do
if c[1] == fx and c[2] == fy then
game.stack:push(TextBox.new(game,
(game.data.text or {})._NewBicycleText or "A shiny new\nBICYCLE!"))
return true
end
end
return false
end,
talk = {
-- BikeShopMiddleAgedWomanText (pokered/scripts/BikeShop.asm):
-- always shows the same flavor line, no branching.
-1
View File
@@ -168,7 +168,6 @@ return {
{ "jump_if_true", "come_see" },
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "give_item", "POKE_BALL", 5, false },
{ "text_sound", "Get_Key_Item" }, -- OaksLab.asm:1060
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
{ "jump", "end" },
+1 -11
View File
@@ -516,17 +516,7 @@ M.ROUTE_24 = {
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
done)
else
-- scripts/Route24.asm:125
ow:engageTrainer(npc, function()
if ow:trainerDefeated(npc) then
-- scripts/Route24.asm:62
push(game,
text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
done)
else
done()
end
end, text(game)._Route24CooltrainerM1DefeatedText, true)
ow:engageTrainer(npc, done)
end
end
if not flags.EVENT_GOT_NUGGET then
+4 -5
View File
@@ -7,9 +7,9 @@ local M = {}
local function text(game) return game.data.text end
local function push(game, s, done, opts)
local function push(game, s, done)
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, done, opts))
game.stack:push(TextBox.new(game, s, done))
end
-- PrintText on a text_end string returns with the box still drawn and
@@ -236,6 +236,7 @@ M.CINNABAR_GYM = {
if yes == machine.yes then
-- CinnabarGymQuizCorrectText: item jingle, then the gate
-- slides open (SFX_GO_INSIDE) if it was still locked
Sound.play(game.data, "Get_Item1")
push(game, t._CinnabarGymQuizCorrectText
or "You're absolutely\ncorrect!\fGo on through!", function()
if not game.save.flags[gymGateFlag(index)] then
@@ -243,9 +244,7 @@ M.CINNABAR_GYM = {
Sound.play(game.data, "Go_Inside")
end
applyGymGates(game, ow)
end, { preSound = function()
return Sound.play(game.data, "Get_Item1")
end })
end)
return
end
Sound.play(game.data, "Denied")
+6 -6
View File
@@ -17,9 +17,9 @@ local function surfingPikachu(game)
return nil
end
local function push(game, text, done, opts)
local function push(game, text, done)
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, text, done, opts))
game.stack:push(TextBox.new(game, text, done))
end
-- the two-variant posters: the surf-capable line once a surfing
@@ -69,11 +69,11 @@ return {
TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done)
local t = game.data.text
-- scripts/SummerBeachHouse.asm:68
push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!",
done, { auto = { wait = true, delay = 0, sound = function()
return require("src.core.Sound").playCry(game.data, "PIKACHU")
end } })
function()
require("src.core.Sound").playCry(game.data, "PIKACHU")
done()
end)
end,
TEXT_SUMMERBEACHHOUSE_POSTER1 = poster(1),
+3 -2
View File
@@ -541,8 +541,9 @@ gains a field instead of the name gaining a prefix.
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
`battle.catch_exp`, `battle.bottom_ui_visible` and
`battle.status_hud_visible`. One payload difference: Gen 1's vanilla
`battle.catch_exp`, `battle.bottom_ui_visible`,
`battle.status_hud_visible` and `battle.move_grid_navigation`. One payload
difference: Gen 1's vanilla
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
+1 -1
View File
@@ -11,7 +11,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Persistent custom options** stored separately from game saves
* **Optional widescreen battle layout**
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders
* **Pokédex diploma and printer image exports**
## Gen 2 Specifics
+9 -14
View File
@@ -95,22 +95,18 @@ auto-rotates like a RetroArch one. Item `frame` rects are top-left plus size in
`extendedEdges` merge per key into the reach fields; `mask: "circle"` becomes a
radial hitbox. A `dpad` or `thumbstick` item expands into the 3x3 grid, so the
corners fire two directions. `screens[1].outputFrame` (or the legacy
`gameScreenFrame`) becomes the screen cutout. A portrait page with neither
keeps `mappingSize` as the overlay aspect, sits at the bottom of the
window, and puts the Game Boy picture in the leftover space above -- the
usual GBA4iOS controller-deck layout. Pages that name a screen rect still
stretch to the window the way Delta does. Host functions map to
`gameScreenFrame`) becomes the screen cutout, and the skin stretches to the
window the way Delta does rather than letterboxing. Host functions map to
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
Both `com.rileytestut.delta.game.*` and Manic's `public.aoshuang.game.*`
identifiers are accepted, and a non Game Boy system warns instead of failing.
PDF artwork is usually a JPEG wrapped so iOS can scale it (Delta's
Image-to-PDF skins, Preview exports, and the like). Import extracts that
JPEG and draws it; a true vector PDF with no embedded image is still refused,
with a message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files
are an older, incompatible schema and are refused by name.
PDF artwork is the one thing that does not come across: Delta's own templates
are all-PDF and this engine has no rasterizer, so such a skin is refused with
the message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files are
an older, incompatible schema and are refused by name.
## Bindable actions
@@ -272,7 +268,6 @@ exported file** opens that folder.
## Not implemented
True vector Delta skins (PDF artwork with no embedded JPEG). Those still need
a PDF renderer this engine does not carry, so they are refused with a message
rather than imported half-drawn. PDF files that wrap a JPEG, the usual Delta
skin case, extract on import.
Delta skins whose art is PDF only. Rasterizing them needs a PDF renderer this
engine does not carry, so they are refused with a message rather than imported
half-drawn.
+70
View File
@@ -12,6 +12,76 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.2.7",
"date": "2026-08-18",
"size": 13597177,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.7/gen1recomp++-0.2.7-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1005 (Android) Screen record mutes the game\n- #1291 Audio Crash\n- #1310 Incoming call crashes G1R\n- #1471 [Gold] #1117 still not fixed\n- #1528 Surfing Minigame doesn't play as intended\n- #1537 Shellder and Corsola missing from Rod encounter tables\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @castdrian"
},
{
"version": "0.2.6",
"date": "2026-08-18",
"size": 13589036,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.6/gen1recomp++-0.2.6-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1393 [Launcher -> Mods] Only the pages that you manually clicked to are used for sorting\n- #1418 (Pokémon Gold) Framerate and void fill options missing\n- #1430 [Gold] Shop ui off because of a border\n- #1519 Poison damage after battle inconsistent\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.5",
"date": "2026-08-18",
"size": 13586158,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.5/gen1recomp++-0.2.5-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1390 switch and gold\n- #1503 Retroarch Skin Problem\n- #1508 Please check #1412 & #1414 again, we had a misunderstanding\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.4",
"date": "2026-08-18",
"size": 13582747,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.4/gen1recomp++-0.2.4-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1496 Investigate Security according to https://hdbreaker.github.io/blog/pokemon-gen1recomp-hate-cheat/\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.3",
"date": "2026-08-18",
"size": 13579254,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.3/gen1recomp++-0.2.3-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1497 skin studio needs a import file picker babyyyyyy\n\n## Contributors\n\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi\n- @thibautbus"
},
{
"version": "0.2.2",
"date": "2026-08-18",
"size": 13575387,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.2/gen1recomp++-0.2.2-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
},
{
"version": "0.2.1",
"date": "2026-08-17",
"size": 13575320,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.1/gen1recomp++-0.2.1-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.0",
"date": "2026-08-17",
"size": 13575299,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.0/gen1recomp++-0.2.0-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1396 Nurse dialogue & options\n- #1398 Alignment of options for changing Pokemon\n- #1400 Flying Bug\n- #1401 [Gold] battlergfx $d9/$da load the wrong row count (jumptable crossed vs macro names)\n- #1406 Magikarp salesman dialogue issues\n- #1407 Not able to nickname Magikarp\n- #1411 No indication for stone evolutions\n- #1413 Using a stone closes menu\n- #1415 Super Nerd dialogue issues\n- #1416 (Pokémon Gold) Pokédex doesn't register other trainers' pokémon as seen\n- #1417 (Pokémon Gold) Pokémon you get in trade aren't being registered as caught\n- #1419 (Pokémon Gold) Deposited pokémon don't get healed\n- #1421 (Pokémon Gold) Bad status and catch state appears on the HUD before they should\n- #1422 (Pokémon Gold) Impossible to have the pokédex register Ditto as caught after it transforms\n- #1423 (Pokémon Gold) No save prompt before changing boxes in the PC\n- #1424 (Pokémon Gold) Quantity for owned TMs not being displayed\n- #1425 (Pokémon Gold) Items quantity in your bag should be alligned to the right\n- #1427 (Pokémon Gold) Can't switch items' position in your bag\n- #1428 (Pokémon Gold) Game doesn't show how many pokémon other trainers have\n- #1429 Pikachu not sliding in before its cry. Stuck on standard pokeball release animation.\n- #1431 Shiny sparkle does not play on your sent out shiny pokemon\n- #1432 Experimental marked mods don't install Android\n- #1433 (Pokémon Gold) Missing prompt for depositing pokémon\n- #1435 When npcs stop you to talk or when you walk up to npcs to talk to them sometimes the player has the wrong sprite\n- #1437 Issues with player sprite on map\n- #1440 hold a direction during cutscene and face the wrong way\n- #1441 Magnet Train missing animation\n- #1442 Radio dial is missing in PokeGear radio\n- #1443 Skipping production logo also skips battle scene\n- #1444 Pokemon lack type immunity to status moves\n- #1447 Soft-lock on Cinnabar Island\n- #1449 Visual error on Route 28\n- #1456 Activating all mods doesn't work properly\n- #1461 #1265 didnt got fixed.\n- #1464 Experiance shared in battle\n- #1465 Changing Touch Layout crashes launcher\n- #1466 #1403 Still Happens\n- #1467 A clearer definition of the use of AI for this reconstruction\n- #1468 [Gold] BICYCLE is broken and some pokegear bug\n- #1469 [Gold] status effects aren't shown in the party overlay or the summary screen of the pokemon\n- #1470 Mod updater doesn't work properly when AppImage is running through Steam or Game Mode (Steam Deck)\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.99",
"date": "2026-08-17",
"size": 11391467,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.99/gen1recomp++-0.1.99-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #597 Pulling mod index fails on Android\n- #1403 Save editor not allowing moves to go past ZAP_CANNON\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @emre155\n- @sanjinpepic\n- @ShaneMcGovernIE\n- @syybott\n- @thibautbus"
},
{
"version": "0.1.98",
"date": "2026-08-16",
"size": 11380117,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.98/gen1recomp++-0.1.98-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1181 Poison seems to trigger twice during the poisoned pokemon's turn\n- #1211 S.S. Anne Visual bug when it's sailing away\n- #1212 the move payday does not grant money in gen 2\n- #1214 Title Screen with OG Red is the wrong color\n- #1224 Windowed and borderless toggle in Gold\n- #1228 No option to nickname starter\n- #1229 Encounter rate grace period not working\n- #1230 Couple of sound effects missing\n- #1231 Using Tackle partially distorts battle sprites\n- #1232 Wild pokemon's sprite disappears early when using a pokeball\n- #1249 Cant use stat items IE HP UP PP UP PROTIEN\n- #1251 You don't have a COIN CASE\n- #1265 Major: Regression from #984 (probably?)\n- #1267 [GOLD] POKEDEX didn't show pokemon appear area\n- #1269 [Gold] shadow ball should be invert the screen\n- #1271 [Gold] substitute image broken/not shown\n- #1272 [Gold] swift still checks accuracy and/or evasion\n- #1273 S.S. Anne Issues\n- #1276 Nurse back to not bowing (and turning)\n- #1279 Rival still not looking at player when initiating first fight\n- #1282 PKMN league PC option missing\n- #1293 Dig animation is bugged in-battle\n- #1296 Opponent's moves failing\n- #1298 Gen1 sound tracks have a fade in period, if you enter a route and immediately exit it while this transition is going on it will land on the wrong music\n- #1301 Pixels aren't square\n- #1303 Animation speed of walking NPCs too slow\n- #1305 Wrong Pikachu cry when getting defeated\n- #1307 Rival theme broken after initial fight in Yellow\n- #1318 Thunder Wave works on Ground-types\n- #1328 Message for turning on the PC missing\n- #1329 Name Select Background\n- #1330 Message before looking at map missing\n- #1331 Messages in Oak's lab missing\n- #1333 E-mail in Oak's lab missing\n- #1334 Missing message after picking starter\n- #1335 No Money Box\n- #1338 Rival's sister missing dialogue and roaming\n- #1340 Color palett doesn't affect attack animations\n- #1341 Pokedex entries look wrong\n- #1343 No dashes in empty attack slots during fights\n- #1344 Town Map not showing player sprite\n- #1345 Wrong health color on OG palett\n- #1346 Health still black when viewing stats\n- #1360 No Surfing Music\n- #1362 Poison damage does not flash the screen\n- #1368 Fishing Rods behaving irregularly\n- #1385 Team Rocket Hideouts missing music\n- #1388 Safeguard targets opponent, not user\n- #1389 Gastly unobtainable\n- #1391 NPC not escorting player to museum\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.97",
"date": "2026-08-16",
-21
View File
@@ -6,27 +6,6 @@
// Registered from a constructor so no LÖVE/SDL source needs to know about it.
#import <UIKit/UIKit.h>
#import <sys/utsname.h>
@interface GRDeviceBridge : NSObject
+ (NSString *)deviceModel;
@end
@implementation GRDeviceBridge
+ (NSString *)deviceModel
{
#if TARGET_OS_SIMULATOR
NSString *simulatorModel = NSProcessInfo.processInfo.environment[@"SIMULATOR_MODEL_IDENTIFIER"];
if (simulatorModel.length > 0) return simulatorModel;
#endif
struct utsname systemInfo;
if (uname(&systemInfo) == 0) {
NSString *model = [NSString stringWithUTF8String:systemInfo.machine];
if (model.length > 0) return model;
}
return @"";
}
@end
__attribute__((constructor))
static void GRBootstrapInstall(void)
-29
View File
@@ -156,7 +156,6 @@ int w_syncHealthSteps(lua_State *L)
""" % MARKER
WRAP_REGISTRATION = """#ifdef LOVE_IOS
{ "getDeviceModel", w_getDeviceModel },
{ "pickFile", w_pickFile },
{ "pickFileKinds", w_pickFileKinds },
{ "createFile", w_createFile },
@@ -203,7 +202,6 @@ int w_syncHealthSteps(lua_State *L)
"""
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
{ "getDeviceModel", w_getDeviceModel },
{ "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload },
{ "httpRequest", w_httpRequest },
@@ -212,33 +210,6 @@ WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
BRIDGE_EXTRA_FUNCS = """
#ifdef LOVE_IOS
int w_getDeviceModel(lua_State *L)
{
Class cls = objc_getClass("GRDeviceBridge");
if (cls == nullptr)
{
lua_pushnil(L);
return 1;
}
typedef id (*GRObj)(Class, SEL);
id value = ((GRObj)objc_msgSend)(cls, sel_registerName("deviceModel"));
if (value == nullptr)
{
lua_pushnil(L);
return 1;
}
typedef const char *(*GRUTF8)(id, SEL);
const char *bytes = ((GRUTF8)objc_msgSend)(value,
sel_registerName("UTF8String"));
if (bytes == nullptr || bytes[0] == '\\0')
{
lua_pushnil(L);
return 1;
}
lua_pushstring(L, bytes);
return 1;
}
int w_httpDownload(lua_State *L)
{
const char *url = luaL_checkstring(L, 1);
-1
View File
@@ -1 +0,0 @@
a5522634f1e581a1ebab73bf3ab4bd7a853b7a3e
-1
View File
@@ -1 +0,0 @@
97d4465e8c81099f79696ea4b4bb8b1f9083bc1a
-1
View File
@@ -1 +0,0 @@
12.0
-19
View File
@@ -1,19 +0,0 @@
# macOS build
The desktop app uses the pinned LÖVE 12 runtime built from the LÖVE source
tree and the matching Apple dependency repository. The runtime enables Metal
and is fused into the branded `gen1recomp++.app` bundle.
Build the runtime and desktop app from the repository root:
```bash
scripts/build_love_macos.sh --fetch
LOVE_APP="$PWD/.bazinga/love12/love.app" scripts/build.sh mac --no-notarize --identity -
```
The source and dependency revisions are recorded in `LOVE_SOURCE_REF` and
`APPLE_DEPENDENCIES_REF`. Delete `.bazinga/love12/source` or use `--clean`
when changing those pins.
The packaged executable is `gen1recomp++` inside `gen1recomp++.app`, and the
bundle declares LÖVE 12.0 compatibility.

Before

Width:  |  Height:  |  Size: 318 B

After

Width:  |  Height:  |  Size: 318 B

Before

Width:  |  Height:  |  Size: 687 B

After

Width:  |  Height:  |  Size: 687 B

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

Before

Width:  |  Height:  |  Size: 245 B

-107
View File
@@ -1,107 +0,0 @@
-- Gallery entry: reshape Oak's intro speech with extra questions, sprite
-- swaps (oak / rival / player / pokemon / a custom image), and answers
-- that land in mod.save.
--
-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered.
return function(mod)
local toastPic = mod.path .. "/assets/toast_kid.png"
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
steps = next(steps, speech)
-- after oak says hello, immediately derail
mod.ui.insertStepAfter(steps, "oak_welcome", {
id = "silly_quiz_intro",
kind = "say",
pic = "oak",
text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.",
})
mod.ui.insertStepAfter(steps, "silly_quiz_intro", {
id = "silly_toast",
kind = "yesno",
pic = "oak",
saveKey = "likes_toast",
text = "Do you like\ntoast?",
})
-- brand new sprite mid-speech
mod.ui.insertStepAfter(steps, "silly_toast", {
id = "silly_toast_kid",
kind = "say",
pic = { type = "image", path = toastPic },
reveal = "fade",
saveKey = nil,
text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.",
})
-- existing mon with a wipe + cry, parked after the real demo mon
mod.ui.insertStepAfter(steps, "demo_mon", {
id = "silly_mew",
kind = "say",
pic = { type = "pokemon", id = "MEW" },
reveal = "wipe",
cry = "MEW",
text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.",
})
mod.ui.insertStepAfter(steps, "silly_mew", {
id = "silly_snack",
kind = "choice",
pic = "oak",
saveKey = "snack",
text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.",
choices = { "BERRIES", "LEFTOVERS", "OLD ROD" },
})
-- swap to rival pic for a loaded question before naming him
mod.ui.insertStepBefore(steps, "ask_rival_name", {
id = "silly_trust",
kind = "choice",
pic = "rival",
reveal = "fade",
saveKey = "trusts_rival",
text = "Look at this kid.\nTrustworthy?",
choices = { "SURE", "NO" },
values = { true, false },
})
-- player pic for one last bit after both names are set
mod.ui.insertStepAfter(steps, "name_rival", {
id = "silly_pineapple",
kind = "yesno",
pic = "player",
saveKey = "pineapple_on_pizza",
text = "{PLAYER}. Be honest.\nPineapple on\vpizza?",
})
mod.ui.insertStepAfter(steps, "silly_pineapple", {
id = "silly_closing",
kind = "say",
pic = "oak",
text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.",
})
return steps
end)
-- every answered step with a saveKey lands in mod.save (and therefore
-- save.modData[mod.id] once the slot is written)
mod.events:on("intro.oak_speech.answered", function(ev)
if not ev.saveKey then return end
mod.save:set(ev.saveKey, ev.value)
mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value))
end)
mod.events:on("intro.oak_speech.finished", function(ev)
local answers = ev.answers or {}
for key, value in pairs(answers) do
if mod.save:get(key) == nil then
mod.save:set(key, value)
end
end
mod.save:set("quiz_done", true)
mod.log:info("silly oak quiz done")
end)
end
@@ -1,15 +0,0 @@
{
"id": "example_silly_oak",
"name": "Silly Oak Intro Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "UI",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save."
}
-21
View File
@@ -1,21 +0,0 @@
-- Sharing metadata for the manager detail pane.
return {
summary = "Oak asks dumb questions during the intro and remembers your answers.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "intro", "ui", "oak", "hooks" },
differences = {
changed = {
"Oak's NEW GAME speech gains extra questions and sprite beats",
},
added = {
"mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done",
"Custom Toast Kid pic mid-intro",
},
known = { "vanilla naming and the shrink-away still run" },
},
credits = {
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" },
},
compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 },
}
@@ -1,159 +0,0 @@
-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors,
-- and answers landing in mod.save.
--
-- Needs an imported ROM dataset (data/generated/). Headless CI and a
-- fresh checkout without a ROM skip cleanly -- the gallery is also
-- covered by tests/mod_examples_tests.lua when generated data is present.
package.path = "./?.lua;./?/init.lua;" .. package.path
local function hasGenerated()
local handle = io.open("data/generated/constants.lua", "r")
if handle then handle:close() return true end
return false
end
if not hasGenerated() then
print("example_silly_oak_test skipped (needs data/generated/)")
os.exit(0)
end
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local OakSpeech = require("src.ui.OakSpeech")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
local mod = run.mod
T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state")
local ModUI = require("src.ui.ModUI")
local bucket = function()
return run.loader.modSave.example_silly_oak or {}
end
local toastPath = (mod.path or "mods/examples/example_silly_oak")
.. "/assets/toast_kid.png"
-- ------- build hook injects every silly beat around vanilla anchors
local speech = OakSpeech.new({
data = Data,
save = { player = { name = "RED", rival = "BLUE" } },
stack = { push = function() end, pop = function() end },
}, nil)
local steps = speech:buildSteps()
local ids = {}
for _, step in ipairs(steps) do ids[#ids + 1] = step.id end
local function has(id)
for _, x in ipairs(ids) do if x == id then return true end end
return false
end
T.check(has("oak_welcome") and has("name_player") and has("shrink"),
"vanilla anchors still present")
T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"),
"toast quiz beats injected")
T.check(has("silly_mew") and has("silly_snack"),
"MEW reveal and snack choice injected")
T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"),
"rival trust + pineapple beats injected")
-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask
local function indexOf(id)
for i, x in ipairs(ids) do if x == id then return i end end
return 0
end
T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"),
"Toast Kid shows before the demo mon")
T.check(indexOf("demo_mon") < indexOf("silly_mew"),
"MEW shows after the demo mon")
T.check(indexOf("silly_trust") < indexOf("ask_rival_name"),
"trust question is before rival naming")
T.check(indexOf("name_rival") < indexOf("silly_pineapple")
and indexOf("silly_pineapple") < indexOf("legend"),
"pineapple lands between rival name and the legend beat")
-- ------- step shapes cover choice / yesno / custom image / pokemon
local byId = {}
for _, step in ipairs(steps) do byId[step.id] = step end
T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no")
T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast")
T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice")
T.eq(#byId.silly_snack.choices, 3, "snack has three options")
T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image",
"Toast Kid uses a custom image pic")
T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon"
and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW",
"MEW beat uses pokemon pic + cry")
T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic")
-- ------- resolvePic covers trainer / pokemon / player / image shorthand
local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech)
local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech)
local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech)
local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data },
{ type = "pokemon", id = "MEW", flip = true }, speech)
local customImg = OakSpeech.resolvePic({ data = Data },
{ type = "image", path = toastPath }, speech)
-- headless love stub may return nil images; the call itself must not throw
T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata"
or type(oakImg) == "table",
"oak shorthand resolves without error")
T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata"
or type(rivalImg) == "table",
"rival shorthand resolves without error")
T.check(playerImg == speech.playerPic or playerImg == nil
or type(playerImg) == "userdata" or type(playerImg) == "table",
"player shorthand resolves without error")
T.check(mewFlip == true, "pokemon flip flag is honored")
T.check(customImg ~= nil or true, "custom image path is accepted")
-- ------- answered event writes mod.save (loader.modSave bucket)
Runtime.emit("intro.oak_speech.answered", {
saveKey = "likes_toast", value = true, label = "YES", index = 1,
step = byId.silly_toast, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3,
step = byId.silly_snack, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "trusts_rival", value = false, label = "NO", index = 2,
step = byId.silly_trust, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1,
step = byId.silly_pineapple, speech = speech,
})
Runtime.emit("intro.oak_speech.finished", {
speech = speech, answers = speech.answers,
})
local saved = bucket()
T.eq(saved.likes_toast, true, "likes_toast saved")
T.eq(saved.snack, "OLD ROD", "snack saved")
T.eq(saved.trusts_rival, false, "trusts_rival saved")
T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved")
T.eq(saved.quiz_done, true, "quiz_done stamped on finish")
-- ------- ModUI step helpers (public surface)
local tiny = {
{ id = "a", kind = "say" },
{ id = "b", kind = "say" },
}
ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" })
T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor")
ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" })
T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor")
ModUI.removeStep(tiny, "mid")
T.check(tiny[2].id ~= "mid", "removeStep drops by id")
run.release()
T.finish("example_silly_oak")
+15 -75
View File
@@ -8,7 +8,7 @@
# [--notary-profile NAME] [--no-notarize]
# [--release] # ios only: release config instead of debug
#
# Output: dist/mac/gen1recomp++-macos.zip
# Output: dist/mac/gen1recomp-macos.zip
# dist/win/gen1recomp-win64.zip
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage)
# dist/android/debug/*.apk (full gradle output stays under
@@ -27,18 +27,13 @@ ENTITLEMENTS="$ROOT/scripts/macos-entitlements.plist"
APP_NAME="gen1recomp"
BUNDLE_ID="com.theboisclub.pokemonred"
MAC_APP_NAME="gen1recomp++"
MAC_BUNDLE_ID="com.theboisclub.gen1recompplusplus"
LOVE_VERSION="11.5"
LOVE_MAC_VERSION="$(tr -d '[:space:]' < "$ROOT/mobile/macos/LOVE_VERSION" 2>/dev/null || echo 12.0)"
LOVE_MAC_APP="$HERE/love12/love.app"
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
VERSION_EXPLICIT=false
IDENTITY=""
TARGET="all"
NOTARY_PROFILE="notary-profile"
NOTARIZE=true
FETCH_MAC_RUNTIME=false
IOS_RELEASE=false
IOS_IPA=false
@@ -46,22 +41,6 @@ say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
strip_bundle_metadata() {
local bundle="$1"
xattr -rc "$bundle"
xattr -rd com.apple.FinderInfo "$bundle" 2>/dev/null || true
xattr -rd 'com.apple.fileprovider.fpfs#P' "$bundle" 2>/dev/null || true
}
valid_love12_app() {
local app="$1" version
[ -x "$app/Contents/MacOS/love" ] || return 1
version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist" 2>/dev/null || true)"
printf '%s' "$version" | grep -Eq '^12(\.|$)' || return 1
otool -L "$app/Contents/Frameworks/love.framework/love" 2>/dev/null \
| grep -q '/Metal.framework/'
}
while [ $# -gt 0 ]; do
case "$1" in
mac|win|linux|android|ios|all) TARGET="$1" ;;
@@ -69,7 +48,6 @@ while [ $# -gt 0 ]; do
--identity) IDENTITY="$2"; shift ;;
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
--no-notarize) NOTARIZE=false ;;
--fetch) FETCH_MAC_RUNTIME=true ;;
--release) IOS_RELEASE=true ;;
--ipa) IOS_IPA=true ;;
*) fail "unknown argument: $1" ;;
@@ -172,55 +150,24 @@ make_ico() { # $1 = output .ico path
# --------------------------------------------------------------- macOS
build_mac() {
say "building macOS app"
local love_app="${LOVE_APP:-}"
if [ -z "$love_app" ]; then
for candidate in "$LOVE_MAC_APP" "/Applications/love12.app" "$HOME/Applications/love12.app" \
"/Applications/love.app" "$HOME/Applications/love.app"; do
if [ -d "$candidate" ] && valid_love12_app "$candidate"; then
love_app="$candidate"
break
fi
done
fi
if [ ! -d "$love_app" ] && [ -z "${LOVE_APP:-}" ] && $FETCH_MAC_RUNTIME; then
"$ROOT/scripts/build_love_macos.sh" --fetch
love_app="$LOVE_MAC_APP"
fi
[ -d "$love_app" ] || fail "LÖVE 12 Metal app not found; run scripts/build_love_macos.sh --fetch or set LOVE_APP=/path/to/love.app"
local love_version
love_version=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$love_app/Contents/Info.plist" 2>/dev/null || true)
printf '%s' "$love_version" | grep -Eq '^12(\.|$)' \
|| fail "macOS build requires LÖVE $LOVE_MAC_VERSION at $love_app (set LOVE_APP to a LÖVE 12 app)"
[ -f "$love_app/Contents/Frameworks/love.framework/love" ] \
|| fail "macOS build requires love.framework at $love_app"
otool -L "$love_app/Contents/Frameworks/love.framework/love" \
| grep -q '/Metal.framework/' \
|| fail "macOS build requires a LÖVE runtime linked to Metal at $love_app"
local love_app="${LOVE_APP:-/Applications/love.app}"
[ -d "$love_app" ] || fail "LÖVE.app not found at $love_app (install it or set LOVE_APP=/path/to/love.app)"
local stage_dir="${MAC_STAGE_DIR:-${RUNNER_TEMP:-/tmp}/gen1recomp-mac-stage}"
local out_app="$stage_dir/$MAC_APP_NAME.app"
mkdir -p "$stage_dir"
local out_app="$WORK/$APP_NAME.app"
rm -rf "$out_app"
ditto --norsrc "$love_app" "$out_app"
local love_executable="$out_app/Contents/MacOS/love"
local app_executable="$out_app/Contents/MacOS/$MAC_APP_NAME"
[ -f "$love_executable" ] || fail "LÖVE app is missing Contents/MacOS/love"
mv "$love_executable" "$app_executable"
cp -R "$love_app" "$out_app"
# drop any bundled placeholder .love and fuse ours in
find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete
cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love"
local plist="$out_app/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleName $MAC_APP_NAME" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleName string $MAC_APP_NAME" "$plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $MAC_APP_NAME" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleDisplayName string $MAC_APP_NAME" "$plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleExecutable $MAC_APP_NAME" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleExecutable string $MAC_APP_NAME" "$plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $MAC_BUNDLE_ID" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string $MAC_BUNDLE_ID" "$plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleName string $APP_NAME" "$plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $APP_NAME" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleDisplayName string $APP_NAME" "$plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string $BUNDLE_ID" "$plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string $VERSION" "$plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$plist" 2>/dev/null \
@@ -242,11 +189,6 @@ build_mac() {
/usr/libexec/PlistBuddy -c "Set :CFBundleIconFile OS X AppIcon" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIconFile string 'OS X AppIcon'" "$plist"
strip_bundle_metadata "$out_app"
rm -rf "$WORK/$MAC_APP_NAME.app"
ln -s "$out_app" "$WORK/$MAC_APP_NAME.app"
local id="$IDENTITY"
if [ -z "$id" ]; then
id="$(security find-identity -v -p codesigning 2>/dev/null | grep 'Developer ID Application' | head -1 | sed -E 's/^[^"]*"(.*)"$/\1/' || true)"
@@ -268,9 +210,9 @@ build_mac() {
warn "keychain profile '$NOTARY_PROFILE' not found/working, skipping notarization."
warn "set it up with: xcrun notarytool store-credentials \"$NOTARY_PROFILE\" --apple-id ... --team-id ... --password ..."
else
local notarize_zip="$stage_dir/$MAC_APP_NAME-notarize.zip"
local notarize_zip="$WORK/$APP_NAME-notarize.zip"
rm -f "$notarize_zip"
(cd "$stage_dir" && ditto -c -k --keepParent "$MAC_APP_NAME.app" "$notarize_zip")
(cd "$WORK" && ditto -c -k --keepParent "$APP_NAME.app" "$notarize_zip")
say "submitting to Apple notary service (this can take a few minutes)"
xcrun notarytool submit "$notarize_zip" --keychain-profile "$NOTARY_PROFILE" --wait
say "stapling notarization ticket"
@@ -279,11 +221,9 @@ build_mac() {
fi
fi
strip_bundle_metadata "$out_app"
local zip_out="$DIST/mac/$MAC_APP_NAME-macos.zip"
local zip_out="$DIST/mac/$APP_NAME-macos.zip"
rm -f "$zip_out"
(cd "$stage_dir" && ditto -c -k --norsrc --keepParent "$MAC_APP_NAME.app" "$zip_out")
(cd "$WORK" && ditto -c -k --sequesterRsrc --keepParent "$APP_NAME.app" "$zip_out")
say "macOS build: $zip_out"
}
-151
View File
@@ -1,151 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MACOS_DIR="$ROOT/mobile/macos"
CACHE="$ROOT/.bazinga/love12"
SOURCE_DIR="${LOVE_SOURCE_DIR:-$CACHE/source}"
RUNTIME_APP="${LOVE_APP_OUTPUT:-$CACHE/love.app}"
BUILD_DIR="$CACHE/build"
LOVE_VERSION="$(tr -d '[:space:]' < "$MACOS_DIR/LOVE_VERSION")"
LOVE_SOURCE_REF="$(tr -d '[:space:]' < "$MACOS_DIR/LOVE_SOURCE_REF")"
APPLE_DEPENDENCIES_REF="$(tr -d '[:space:]' < "$MACOS_DIR/APPLE_DEPENDENCIES_REF")"
LOVE_SOURCE_REPO="${LOVE_SOURCE_REPO:-https://github.com/love2d/love.git}"
APPLE_DEPENDENCIES_REPO="${APPLE_DEPENDENCIES_REPO:-https://github.com/love2d/love-apple-dependencies.git}"
FETCH=false
CLEAN=false
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
strip_bundle_metadata() {
local bundle="$1"
xattr -rc "$bundle"
xattr -rd com.apple.FinderInfo "$bundle" 2>/dev/null || true
xattr -rd 'com.apple.fileprovider.fpfs#P' "$bundle" 2>/dev/null || true
}
while [ $# -gt 0 ]; do
case "$1" in
--fetch) FETCH=true ;;
--clean) CLEAN=true ;;
-h|--help)
printf '%s\n' 'usage: scripts/build_love_macos.sh [--fetch] [--clean]'
exit 0
;;
*) fail "unknown argument: $1" ;;
esac
shift
done
[ "$(uname -s)" = "Darwin" ] || fail "macOS LÖVE builds require Darwin"
command -v git >/dev/null 2>&1 || fail "git is required to fetch LÖVE sources"
command -v xcodebuild >/dev/null 2>&1 || fail "xcodebuild is required to build LÖVE for macOS"
command -v xattr >/dev/null 2>&1 || fail "xattr is required to normalize the runtime bundle"
source_ready() {
[ -d "$SOURCE_DIR/platform/xcode/love.xcodeproj" ] \
&& [ -d "$SOURCE_DIR/platform/xcode/macosx/Frameworks/Lua.framework" ] \
&& [ -d "$SOURCE_DIR/platform/xcode/shared/Frameworks/SDL3.xcframework" ]
}
source_is_pinned() {
[ "$(git -C "$SOURCE_DIR" rev-parse HEAD 2>/dev/null || true)" = "$LOVE_SOURCE_REF" ] \
&& [ -f "$SOURCE_DIR/.gen1recomp-apple-dependencies-ref" ] \
&& [ "$(tr -d '[:space:]' < "$SOURCE_DIR/.gen1recomp-apple-dependencies-ref")" = "$APPLE_DEPENDENCIES_REF" ]
}
fetch_repo() {
local repo_url="$1"
local ref="$2"
local destination="$3"
mkdir -p "$destination"
git -C "$destination" init -q
git -C "$destination" remote add origin "$repo_url"
git -C "$destination" fetch --depth 1 origin "$ref"
git -C "$destination" checkout --detach -q FETCH_HEAD
}
fetch_sources() {
local tmp
tmp="$(mktemp -d "$CACHE/fetch.XXXXXX")"
say "fetching LÖVE source $LOVE_SOURCE_REF"
fetch_repo "$LOVE_SOURCE_REPO" "$LOVE_SOURCE_REF" "$tmp/love"
say "fetching Apple dependencies $APPLE_DEPENDENCIES_REF"
fetch_repo "$APPLE_DEPENDENCIES_REPO" "$APPLE_DEPENDENCIES_REF" "$tmp/dependencies"
mkdir -p "$tmp/love/platform/xcode/macosx/Frameworks" "$tmp/love/platform/xcode/shared"
cp -R "$tmp/dependencies/macOS/Frameworks/." "$tmp/love/platform/xcode/macosx/Frameworks/"
cp -R "$tmp/dependencies/shared/." "$tmp/love/platform/xcode/shared/"
rm -rf "$SOURCE_DIR"
mkdir -p "$(dirname "$SOURCE_DIR")"
mv "$tmp/love" "$SOURCE_DIR"
printf '%s\n' "$APPLE_DEPENDENCIES_REF" > "$SOURCE_DIR/.gen1recomp-apple-dependencies-ref"
rm -rf "$tmp"
say "LÖVE source ready at $SOURCE_DIR"
}
if $CLEAN; then
[ -z "${LOVE_SOURCE_DIR:-}" ] \
|| fail "--clean cannot be used with LOVE_SOURCE_DIR"
rm -rf "$SOURCE_DIR" "$BUILD_DIR" "$RUNTIME_APP"
fi
if ! source_ready || ! source_is_pinned; then
if ! $FETCH; then
fail "pinned LÖVE 12 sources are missing at $SOURCE_DIR; run scripts/build_love_macos.sh --fetch"
fi
[ -z "${LOVE_SOURCE_DIR:-}" ] \
|| fail "LOVE_SOURCE_DIR is not the pinned LÖVE commit $LOVE_SOURCE_REF"
fetch_sources
fi
PROJECT="$SOURCE_DIR/platform/xcode/love.xcodeproj"
rm -rf "$BUILD_DIR" "$RUNTIME_APP"
mkdir -p "$BUILD_DIR"
say "building LÖVE 12 macOS runtime"
xcodebuild \
-quiet \
-project "$PROJECT" \
-target love-macosx \
-configuration Release \
-sdk macosx \
SYMROOT="$BUILD_DIR" \
OBJROOT="$BUILD_DIR/Intermediates" \
ARCHS="arm64 x86_64" \
ONLY_ACTIVE_ARCH=NO \
MACOSX_DEPLOYMENT_TARGET=12.0 \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=-
BUILT_APP="$BUILD_DIR/Release/love.app"
[ -d "$BUILT_APP" ] || fail "xcodebuild produced no LÖVE app at $BUILT_APP"
ditto --norsrc "$BUILT_APP" "$RUNTIME_APP"
strip_bundle_metadata "$RUNTIME_APP"
version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$RUNTIME_APP/Contents/Info.plist" 2>/dev/null || true)"
version_re="$(printf '%s' "$LOVE_VERSION" | sed 's/\./\\./g')"
printf '%s' "$version" | grep -Eq "^${version_re}(\.|$)" \
|| fail "built runtime reports LÖVE version '$version'"
[ -x "$RUNTIME_APP/Contents/MacOS/love" ] \
|| fail "built runtime is missing Contents/MacOS/love"
[ -f "$RUNTIME_APP/Contents/Frameworks/love.framework/love" ] \
|| fail "built runtime is missing love.framework"
otool -L "$RUNTIME_APP/Contents/Frameworks/love.framework/love" \
| grep -q '/Metal.framework/' \
|| fail "built LÖVE runtime is not linked to Metal"
archs="$(lipo -archs "$RUNTIME_APP/Contents/MacOS/love")"
printf '%s' "$archs" | grep -qw arm64 \
|| fail "built LÖVE app is missing arm64"
printf '%s' "$archs" | grep -qw x86_64 \
|| fail "built LÖVE app is missing x86_64"
framework_archs="$(lipo -archs "$RUNTIME_APP/Contents/Frameworks/love.framework/love")"
printf '%s' "$framework_archs" | grep -qw arm64 \
|| fail "built LÖVE framework is missing arm64"
printf '%s' "$framework_archs" | grep -qw x86_64 \
|| fail "built LÖVE framework is missing x86_64"
say "LÖVE 12 macOS runtime: $RUNTIME_APP"
+2 -24
View File
@@ -32,30 +32,8 @@ find_love() {
return 1
}
find_love12() {
local app version
for app in "${LOVE_APP:-}" "$ROOT/.bazinga/love12/love.app" "/Applications/love12.app" "$HOME/Applications/love12.app" \
"/Applications/love.app" "$HOME/Applications/love.app"; do
[ -n "$app" ] || continue
if [ -x "$app/Contents/MacOS/love" ]; then
version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist" 2>/dev/null || true)"
if printf '%s' "$version" | grep -Eq '^12(\.|$)' \
&& otool -L "$app/Contents/Frameworks/love.framework/love" 2>/dev/null | grep -q '/Metal.framework/'; then
echo "$app/Contents/MacOS/love"
return
fi
fi
done
return 1
}
if [ "$(uname -s)" = "Darwin" ]; then
LOVE_BIN="$(find_love12)" \
|| fail "LÖVE 12 with Metal not found, run scripts/setup.sh or scripts/build_love_macos.sh --fetch"
else
LOVE_BIN="$(find_love)" \
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)"
fi
LOVE_BIN="$(find_love)" \
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)"
# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors;
# default to X11/XWayland when available to ensure rock-solid drag-drop stability.
+4 -25
View File
@@ -70,32 +70,11 @@ find_love() {
return 1
}
valid_love12_app() {
local app="$1" version
[ -x "$app/Contents/MacOS/love" ] || return 1
version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist" 2>/dev/null || true)"
printf '%s' "$version" | grep -Eq '^12(\.|$)' || return 1
otool -L "$app/Contents/Frameworks/love.framework/love" 2>/dev/null \
| grep -q '/Metal.framework/'
}
if [ "$(uname -s)" = "Darwin" ]; then
if valid_love12_app "$ROOT/.bazinga/love12/love.app"; then
say "LÖVE 12 found: $ROOT/.bazinga/love12/love.app"
elif valid_love12_app "/Applications/love12.app"; then
say "LÖVE 12 found: /Applications/love12.app"
elif valid_love12_app "$HOME/Applications/love12.app"; then
say "LÖVE 12 found: $HOME/Applications/love12.app"
elif valid_love12_app "/Applications/love.app"; then
say "LÖVE 12 found: /Applications/love.app"
elif valid_love12_app "$HOME/Applications/love.app"; then
say "LÖVE 12 found: $HOME/Applications/love.app"
else
say "building LÖVE 12 for macOS"
"$ROOT/scripts/build_love_macos.sh" --fetch
fi
elif LOVE_BIN="$(find_love)"; then
if LOVE_BIN="$(find_love)"; then
say "LÖVE found: $LOVE_BIN"
elif [ "$(uname -s)" = "Darwin" ] && command -v brew >/dev/null 2>&1; then
say "installing LÖVE via Homebrew"
brew install --cask love
else
fail "LÖVE 11.x is not installed; install it from https://love2d.org"
fi
+53 -126
View File
@@ -1008,23 +1008,9 @@ end
-- flickers the OBJ palette, DoBallTossSpecialEffects)
function BattleState:animNext(name, isPlayer, shakes, ball)
self.nextInsert = (self.nextInsert or 0) + 1
local row = { anim = name, attackerIsPlayer = isPlayer, shakes = shakes,
ball = ball }
table.insert(self.queue, self.nextInsert, row)
return row
end
-- an animation row ahead of the move's own, with PlayBattleAnimation2's
-- applying-animation shake (engine/battle/effects.asm:1461-1471)
function BattleState:animBeforeMove(name, isPlayer)
local at
for i, item in ipairs(self.queue) do
if item == self.moveAnimRow then at = i break end
end
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, at or self.nextInsert,
{ anim = name, attackerIsPlayer = isPlayer, animDelayed = true,
hit = { animType = isPlayer and 6 or 3 } })
table.insert(self.queue, self.nextInsert,
{ anim = name, attackerIsPlayer = isPlayer, shakes = shakes,
ball = ball })
end
-- insert an act right after the current queue item
@@ -1568,13 +1554,8 @@ end
function BattleState:sendOutText(name)
local e = self.enemy and self.enemy.mon
local pct = 100
if e and e.hp > 0 then
-- the same routine stamps wLastSwitchInEnemyMonHP
-- (engine/battle/common_text.asm:105-110)
self.lastSwitchInEnemyHP = e.hp
if math.floor(e.stats.hp / 4) > 0 then
pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4))
end
if e and e.hp > 0 and math.floor(e.stats.hp / 4) > 0 then
pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4))
end
if pct >= 70 then return Strings("Go! %s!", name) end
if pct >= 40 then return Strings("Do it! %s!", name) end
@@ -1582,27 +1563,6 @@ function BattleState:sendOutText(name)
return self:romText("_EnemysWeakText", "The enemy's weak!\nGet'm! %s!", name)
end
-- RetreatMon / PlayerMon2Text (engine/battle/common_text.asm:167-243): the
-- adjective reads the enemy HP lost since this mon switched in
function BattleState:withdrawText(name)
local e = self.enemy and self.enemy.mon
local drop = 0
if e and self.lastSwitchInEnemyHP and math.floor(e.stats.hp / 4) > 0 then
drop = math.floor((self.lastSwitchInEnemyHP - e.hp) * 25
/ math.floor(e.stats.hp / 4))
end
local word = ""
if drop <= 0 then
word = self:romText("_EnoughText", "enough!")
elseif drop >= 70 then
word = self:romText("_GoodText", "good!")
elseif drop >= 30 then
word = self:romText("_OKExclamationText", "OK!")
end
return self:romText("_PlayerMon2Text", "%s ", name) .. word
.. self:romText("_ComeBackText", "\nCome back!")
end
-- The cry a mon makes as it takes the field. Yellow does not run its
-- starter Pikachu through PlayCry at all: SendOutMon branches to
-- .starterPikachu (engine/battle/core.asm:1807-1817) and voices PCM
@@ -1856,8 +1816,7 @@ function BattleState:enter()
self.enemySendingOut = true
self:slidePic("foe")
end)
-- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
self:sayAuto(Strings("%s sent\nout %s!", foeName, self.enemy.name))
self:say(Strings("%s sent\nout %s!", foeName, self.enemy.name))
self:act(function()
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
@@ -1886,8 +1845,7 @@ function BattleState:enter()
self.sendingOut = true
self:slidePic("back")
end)
-- _GoText.._PlayerMon1Text carry no prompt (data/text/text_2.asm:1274-1294)
self:sayAuto(self:sendOutText(self.player.name))
self:say(self:sendOutText(self.player.name))
-- then the POOF plays and the mon appears with its cry
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
self:queueSendOutAnim(true)
@@ -2701,28 +2659,22 @@ function BattleState:resolveSwitch(newMon)
self.phase = "messages"
self.afterQueue = "menu"
self:act(function()
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
-- outgoing pic and holds 50 frames before the mon is recalled
self:sayNextAuto(self:withdrawText(self.player.name),
Timing.SWITCH_PLAYER_MON)
self:actNext(function()
self:restoreMimicked(self.player) -- the battle copy leaves with it
local previous = self.player
self.player = makeBattler(self.data, newMon, true, self.game.save)
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player,
previous = previous,
})
self:markParticipant()
sendOutMonCursors(self)
self.sendingOut = true
self:sayNextAuto(self:sendOutText(self.player.name))
self:queueSendOutAnim(false)
end)
self:restoreMimicked(self.player) -- the battle copy leaves with it
local previous = self.player
self.player = makeBattler(self.data, newMon, true, self.game.save)
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player,
previous = previous,
})
self:markParticipant()
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:queueSendOutAnim(false)
end)
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
@@ -2751,12 +2703,7 @@ function BattleState:residualFor(b, opp)
if b.residualDone then return end
b.residualDone = true
local msgs = Status.residual(b, opp, self)
local rec = Status.recordFor(self.data and self.data.statuses, b.mon.status)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
-- engine/battle/core.asm:490-493
if rec and rec.residual then
self:animNext("BURN_PSN_ANIM", b.isPlayer)
end
if b.leechSeeded and b.mon.hp > 0 then
-- the drain plays the ABSORB animation from the healing side
-- (core.asm:506-517 flips hWhoseTurn before PlayMoveAnimation)
@@ -3640,17 +3587,7 @@ function BattleState:executeAction(user, target, action)
markSeen(self.game, self.enemy.mon.species)
-- _AIBattleWithdrawText: "X with-/drew Y!"
self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName))
-- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
self.enemySendingOut = true
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
self:actNext(function()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
self:actNext(function()
self:waitSfxNext(self:playEntranceCry(self.enemy))
end)
end)
self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
return
end
@@ -4340,9 +4277,6 @@ function BattleState:enemyMonFainted()
self:act(function()
local previous = self.enemy
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
-- EnemySendOutFirstMon (core.asm:1359-1363): the fresh foe's HP is the
-- new wLastSwitchInEnemyMonHP baseline RetreatMon measures from
self.lastSwitchInEnemyHP = self.enemy.mon.hp
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides()
@@ -4362,7 +4296,7 @@ function BattleState:enemyMonFainted()
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
self.enemySendingOut = true
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
self:actNext(function()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
@@ -4375,41 +4309,34 @@ function BattleState:enemyMonFainted()
self:act(function()
local mon = shiftSwitchMon
if not mon then return end
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
-- hold, then the recall and the send-out
local previous = self.player
self.player = makeBattler(self.data, mon, true, self.game.save)
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
battler = self.player, previous = previous,
})
-- Taking the SHIFT offer ZEROES wPartyGainExpFlags and
-- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon
-- (EnemySendOutFirstMon tail, core.asm:1436-1443), and SwitchPlayerMon
-- then FLAG_SETs only the mon coming in (core.asm:2424-2433). Without
-- the reset the mon that was out when the enemy fainted -- marked by
-- the send-out act above, which mirrors EnemySendOut's own re-flag
-- (core.asm:1276-1289) -- stayed a participant, so the exp divisor in
-- enemyMonFainted counted two mons and the switch-in earned half the
-- next KO (#275). Voluntary switches (resolveSwitch) and post-faint
-- replacements (openReplacementMenu) must NOT do this: pokered's
-- party-menu SwitchPlayerMon keeps the outgoing mon flagged, which is
-- the deliberate exp-share, and a fainted mon is already dropped by
-- onFaint mirroring RemoveFaintedPlayerMon (core.asm:1002-1007).
self.participants = {}
self:markParticipant()
self.nextInsert = 0
self:sayNextAuto(self:withdrawText(self.player.name),
Timing.SWITCH_PLAYER_MON)
self:actNext(function()
local previous = self.player
self.player = makeBattler(self.data, mon, true, self.game.save)
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
battler = self.player, previous = previous,
})
-- Taking the SHIFT offer ZEROES wPartyGainExpFlags and
-- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon
-- (EnemySendOutFirstMon tail, core.asm:1436-1443), and SwitchPlayerMon
-- then FLAG_SETs only the mon coming in (core.asm:2424-2433). Without
-- the reset the mon that was out when the enemy fainted -- marked by
-- the send-out act above, which mirrors EnemySendOut's own re-flag
-- (core.asm:1276-1289) -- stayed a participant, so the exp divisor in
-- enemyMonFainted counted two mons and the switch-in earned half the
-- next KO (#275). Voluntary switches (resolveSwitch) and post-faint
-- replacements (openReplacementMenu) must NOT do this: pokered's
-- party-menu SwitchPlayerMon keeps the outgoing mon flagged, which is
-- the deliberate exp-share, and a fainted mon is already dropped by
-- onFaint mirroring RemoveFaintedPlayerMon (core.asm:1002-1007).
self.participants = {}
self:markParticipant()
self.nextInsert = 0
sendOutMonCursors(self)
self.sendingOut = true
self:sayNextAuto(self:sendOutText(self.player.name))
self:queueSendOutAnim(false)
end)
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:queueSendOutAnim(false)
end)
return
end
@@ -4599,7 +4526,7 @@ function BattleState:openReplacementMenu()
self.nextInsert = 0
sendOutMonCursors(self)
self.sendingOut = true
self:sayNextAuto(self:sendOutText(self.player.name))
self:sayNext(self:sendOutText(self.player.name))
self:queueSendOutAnim(false)
end,
})
+1 -20
View File
@@ -92,20 +92,6 @@ local function hitCount(ctx, record)
return dist[r + 1]
end
-- engine/battle/effects.asm:119-151 (poison), :194-255 (burn/freeze/paralyze)
local FBP_SIDE_STATUS = { BRN = true, FRZ = true, PAR = true }
local function secondaryStatusFx(battle, user, status)
if status == "PSN" then
local row = battle:animNext(user.isPlayer and "ENEMY_HUD_SHAKE_ANIM"
or "SHAKE_SCREEN_ANIM", user.isPlayer)
row.animDelayed = true
row.hit = { animType = user.isPlayer and 6 or 3 }
elseif FBP_SIDE_STATUS[status] and user.isPlayer then
battle:animNext("ENEMY_HUD_SHAKE_ANIM", true).animDelayed = true
end
end
-- The damaging pipeline, extracted from the performMove monolith: every
-- stage keeps the original's exact check order and rng consumption
-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy ->
@@ -332,12 +318,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- secondary side effects (blocked by fainting)
if record and record.run and record.kind ~= "primary"
and target.mon.hp > 0 and totalDealt > 0 then
local hadStatus = target.mon.status
local msgs = record.run(ctx)
if target.mon.status and target.mon.status ~= hadStatus then
secondaryStatusFx(battle, user, target.mon.status)
end
for _, m in ipairs(msgs) do
for _, m in ipairs(record.run(ctx)) do
battle:sayNext(m)
end
end
-9
View File
@@ -581,15 +581,6 @@ MoveEffects.full = {
end,
},
THRASH_PETAL_DANCE_EFFECT = {
-- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage
-- (data/battle/special_effects.asm:22) and animates the setup turn
beforeAccuracy = function(ctx)
local user = ctx.user
if not user.thrashTurns then
ctx.battle:animBeforeMove(
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
end
end,
afterDamage = function(ctx)
local user = ctx.user
if not user.thrashTurns then
+6 -21
View File
@@ -116,13 +116,11 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles)
if type(assets) ~= "table" then return nil end
pdfFiles = pdfFiles or {}
local raster = {}
local pdfName
for _, key in ipairs(DeltaSkin.ASSET_LADDER) do
local name = pick(assets, key)
if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end
if type(name) == "string" and name ~= "" then
if name:lower():match("%.pdf$") then
pdfName = name
pdfFiles[#pdfFiles + 1] = name
else
raster[#raster + 1] = { key = key, name = name }
@@ -132,14 +130,12 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles)
local resizable = pick(assets, "resizable")
if type(resizable) == "string" and resizable ~= "" then
if resizable:lower():match("%.pdf$") then
pdfName = resizable
pdfFiles[#pdfFiles + 1] = resizable
else
raster[#raster + 1] = { key = "large", name = resizable }
end
end
local pdfPath = pdfName and DeltaSkin.resolveName(pdfName, opts) or nil
if #raster == 0 then return nil, pdfPath end
if #raster == 0 then return nil end
local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH)
local chosen
@@ -149,7 +145,7 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles)
end
end
if not chosen then chosen = raster[#raster].name end
return DeltaSkin.resolveName(chosen, opts), nil
return DeltaSkin.resolveName(chosen, opts)
end
function DeltaSkin.mergeEdges(base, item)
@@ -292,12 +288,10 @@ function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
addWarning(warnings, orient .. " has no mappingSize; assuming 320x240")
end
local imagePath, pdfPath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles)
local page = {
name = orient,
orient = orient,
imagePath = imagePath,
pdfPath = pdfPath,
imagePath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles),
fullScreen = true,
normalized = true,
pixelCoords = false,
@@ -315,14 +309,6 @@ function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
if screen then
page.viewport = screen
page.viewportFill = false
else
-- mappingSize is the overlay, not the device. Portrait controller
-- skins (GBA4iOS-era 320x240 decks, this Pikachu skin, etc.) keep
-- that aspect, sit at the bottom, and leave the leftover for the
-- Game Boy picture. A screens/gameScreenFrame rect still fills.
page.aspectFromCfg = true
page.screenFit = "remainder"
if orient == "portrait" then page.anchor = "bottom" end
end
local baseEdges = pick(obj, "extendedEdges")
@@ -382,6 +368,9 @@ function DeltaSkin.parse(text, opts)
end
end
if #pages == 0 then return nil, "info.json has no usable representation" end
if #pdfFiles > 0 then
addWarning(warnings, "PDF artwork cannot be imported yet")
end
return {
pages = pages,
@@ -401,10 +390,6 @@ function DeltaSkin.needsConversion(skin)
local files = skin.pdfFiles
if type(files) ~= "table" or #files == 0 then return nil end
for _, page in ipairs(skin.pages or {}) do
-- A raster asset, or a JPEG recovered from the PDF at load, means the
-- skin can draw. Parse-only callers still see pdfOnly because they
-- have not run extract yet.
if page.rasterData then return nil end
if page.imagePath then return nil end
end
return { pdfOnly = true, files = files }
+13 -86
View File
@@ -6,49 +6,6 @@ local IssueReport = {}
local FORM_URL = "https://github.com/bryanthaboi/gen1recomp/issues/new"
local TEMPLATE = "bug_report.yml"
local APPLE_MODELS = {
["iPhone14,2"] = "iPhone 13 Pro",
["iPhone14,3"] = "iPhone 13 Pro Max",
["iPhone14,4"] = "iPhone 13 mini",
["iPhone14,5"] = "iPhone 13",
["iPhone14,7"] = "iPhone 14",
["iPhone14,8"] = "iPhone 14 Plus",
["iPhone15,2"] = "iPhone 14 Pro",
["iPhone15,3"] = "iPhone 14 Pro Max",
["iPhone15,4"] = "iPhone 15",
["iPhone15,5"] = "iPhone 15 Plus",
["iPhone16,1"] = "iPhone 15 Pro",
["iPhone16,2"] = "iPhone 15 Pro Max",
["iPhone17,1"] = "iPhone 16 Pro",
["iPhone17,2"] = "iPhone 16 Pro Max",
["iPhone17,3"] = "iPhone 16",
["iPhone17,4"] = "iPhone 16 Plus",
["iPhone17,5"] = "iPhone 16e",
["Mac14,2"] = "MacBook Air (13-inch, M2)",
["Mac14,3"] = "Mac mini (M2)",
["Mac14,5"] = "MacBook Pro (14-inch, M2 Max)",
["Mac14,6"] = "MacBook Pro (16-inch, M2 Max)",
["Mac14,7"] = "MacBook Pro (13-inch, M2)",
["Mac14,9"] = "MacBook Pro (14-inch, M2 Pro)",
["Mac14,10"] = "MacBook Pro (16-inch, M2 Pro)",
["Mac14,12"] = "Mac mini (M2 Pro)",
["Mac14,13"] = "Mac Studio (M2 Max)",
["Mac14,14"] = "Mac Studio (M2 Ultra)",
["Mac14,15"] = "MacBook Air (15-inch, M2)",
["Mac15,3"] = "MacBook Pro (14-inch, M3)",
["Mac15,6"] = "MacBook Pro (14-inch, M3 Pro)",
["Mac15,7"] = "MacBook Pro (16-inch, M3 Pro)",
["Mac15,12"] = "MacBook Air (13-inch, M3)",
["Mac15,13"] = "MacBook Air (15-inch, M3)",
["Mac16,1"] = "MacBook Pro (14-inch, M4)",
["Mac16,5"] = "MacBook Pro (16-inch, M4 Max)",
["Mac16,6"] = "MacBook Pro (14-inch, M4 Max)",
["Mac16,7"] = "MacBook Pro (16-inch, M4 Pro)",
["Mac16,8"] = "MacBook Pro (14-inch, M4 Pro)",
["Mac16,10"] = "Mac mini (M4)",
["Mac16,11"] = "Mac mini (M4 Pro)",
}
local function clean(value)
if value == nil then return nil end
local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "")
@@ -79,16 +36,6 @@ local function commandValue(command)
return clean(value)
end
local function commandText(command)
if not io or type(io.popen) ~= "function" then return nil end
local ok, pipe = pcall(io.popen, command, "r")
if not ok or not pipe then return nil end
local readOK, value = pcall(pipe.read, pipe, "*a")
pcall(pipe.close, pipe)
if not readOK then return nil end
return clean(value)
end
local function percentEncode(value)
local text = tostring(value or "")
return (text:gsub("([^%w%-_%.~])", function(char)
@@ -119,25 +66,6 @@ local function loveVersion()
return result
end
local function friendlyModel(identifier)
identifier = clean(identifier)
if not identifier then return nil end
return APPLE_MODELS[identifier] or identifier
end
local function macModel()
local details = commandText("system_profiler SPHardwareDataType 2>/dev/null")
if details then
local name = clean(details:match("Model Name:%s*([^\r\n]+)"))
local chip = clean(details:match("Chip:%s*([^\r\n]+)"))
if name and chip and not name:find(chip, 1, true) then
return name .. " (" .. chip .. ")"
end
if name then return name end
end
return friendlyModel(commandValue("sysctl -n hw.model 2>/dev/null"))
end
local function appVersion()
local version = clean(Version.engine)
if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end
@@ -145,25 +73,20 @@ local function appVersion()
end
local function deviceModel(rawOS, system)
local nativeModel = clean(call(system.getDeviceModel))
if nativeModel then return friendlyModel(nativeModel) end
if rawOS == "OS X" or rawOS == "macOS" then
return macModel()
end
local model = clean(call(system.getModel))
if model and not model:lower():find("gpu", 1, true)
and not model:lower():find("renderer", 1, true) then
return friendlyModel(model)
if model then return model end
if rawOS == "OS X" or rawOS == "macOS" then
return commandValue("sysctl -n hw.model 2>/dev/null")
end
if rawOS == "Windows" then
return friendlyModel(commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL"))
return commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL")
end
if rawOS == "Linux" then
return friendlyModel(commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null"))
return commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null")
end
if rawOS == "Android" then
return friendlyModel(commandValue("getprop ro.product.model 2>/dev/null"))
return commandValue("getprop ro.product.model 2>/dev/null")
end
return nil
end
@@ -198,7 +121,7 @@ local function metadata(options, context)
local window = love and love.window or {}
local rawOS = clean(call(system.getOS))
local model = deviceModel(rawOS, system)
local renderer, rendererVersion = call(graphics.getRendererInfo)
local renderer, rendererVersion, _, rendererDevice = call(graphics.getRendererInfo)
local width, height = call(graphics.getDimensions)
local pixelWidth, pixelHeight = call(graphics.getPixelDimensions)
local modeWidth, modeHeight, flags = call(window.getMode)
@@ -211,7 +134,11 @@ local function metadata(options, context)
if value then lines[#lines + 1] = "- " .. label .. ": " .. value end
end
add("Platform", formOS(rawOS))
add("Device", model)
local hardware = model
if rendererDevice and rendererDevice ~= model then
hardware = hardware and (hardware .. " (" .. rendererDevice .. ")") or rendererDevice
end
add("Device", hardware)
local rendererDetails = clean(renderer)
if rendererDetails and clean(rendererVersion) then
rendererDetails = rendererDetails .. " " .. clean(rendererVersion)
-115
View File
@@ -1,115 +0,0 @@
-- Recover a raster from a PDF that is really a wrapped JPEG. Delta skins
-- ship artwork that way so iOS can scale it; LOVE has no PDF renderer, so
-- import pulls the embedded image out instead of refusing the skin. True
-- vector PDFs (no Image XObject, no JPEG) still fail.
local PdfImage = {}
local function isPdf(bytes)
return type(bytes) == "string" and bytes:sub(1, 5) == "%PDF-"
end
-- After the `stream` keyword the spec allows \n or \r\n before the bytes.
-- `endstream` also contains the letters "stream", so skip that match.
local function streamDataStart(bytes, from)
local s, e = bytes:find("stream", from, true)
while s do
if s == 1 or bytes:sub(s - 3, s - 1) ~= "end" then
local p = e + 1
if bytes:sub(p, p) == "\r" then p = p + 1 end
if bytes:sub(p, p) == "\n" then p = p + 1 end
return p, s
end
s, e = bytes:find("stream", e + 1, true)
end
return nil
end
local function dictWindow(bytes, imageAt)
local from = imageAt > 400 and (imageAt - 400) or 1
local to = math.min(#bytes, imageAt + 800)
return bytes:sub(from, to)
end
local function dictNumber(window, key)
-- Prefer an indirect ref so `/Length 5 0 R` is not read as length 5.
if window:find("/" .. key .. "%s+%d+%s+%d+%s+R") then return nil end
return tonumber(window:match("/" .. key .. "%s+(%d+)"))
end
local function dictFilter(window)
local named = window:match("/Filter%s*/(%w+)")
if named then return named end
return window:match("/Filter%s*%[%s*/(%w+)")
end
local function jpegIn(bytes, from, to)
if from < 1 then from = 1 end
if not to or to > #bytes then to = #bytes end
if to < from then return nil end
local region = bytes:sub(from, to)
local soi = region:find("\255\216\255", 1, true)
if not soi then return nil end
local eoi = region:find("\255\217", soi + 3, true)
if not eoi then return nil end
return region:sub(soi, eoi + 1)
end
local function candidate(data, width, height, ext)
if not data or data == "" then return nil end
return {
data = data,
ext = ext or "jpg",
width = width or 0,
height = height or 0,
}
end
local function bigger(a, b)
if not a then return b end
if not b then return a end
local as = (a.width or 0) * (a.height or 0)
local bs = (b.width or 0) * (b.height or 0)
if bs ~= as then return bs > as and b or a end
return #b.data > #a.data and b or a
end
-- Walk Image XObjects and take the largest DCTDecode (JPEG) stream.
local function fromImageXObjects(bytes)
local best
local i = 1
while true do
local s, e = bytes:find("/Subtype%s*/Image", i)
if not s then break end
local window = dictWindow(bytes, s)
local filter = dictFilter(window)
local width = dictNumber(window, "Width")
local height = dictNumber(window, "Height")
local dataStart = streamDataStart(bytes, e)
i = e + 1
if dataStart and filter == "DCTDecode" then
local es = bytes:find("endstream", dataStart, true)
local jpeg = jpegIn(bytes, dataStart, es and (es - 1) or nil)
best = bigger(best, candidate(jpeg, width, height, "jpg"))
end
end
return best
end
-- Image-to-PDF converters (3-Heights, Preview, etc.) leave a single JPEG
-- body even when /Length is an indirect object we do not resolve.
local function fromBareJpeg(bytes)
local jpeg = jpegIn(bytes, 1, #bytes)
if not jpeg then return nil end
return candidate(jpeg, 0, 0, "jpg")
end
function PdfImage.extract(bytes)
if not isPdf(bytes) then return nil, "not a pdf" end
local best = fromImageXObjects(bytes)
if not best then best = fromBareJpeg(bytes) end
if not best then return nil, "no extractable image" end
return best
end
return PdfImage
+1 -1
View File
@@ -140,7 +140,7 @@ function SaveData.gameFolders()
local src = love.filesystem.getSource and love.filesystem.getSource()
local sbd = love.filesystem.getSourceBaseDirectory
and love.filesystem.getSourceBaseDirectory()
-- A packaged macOS build nests the game inside gen1recomp++.app/Contents/
-- A packaged macOS build nests the game inside gen1recomp.app/Contents/
-- Resources, so getSource()/getSourceBaseDirectory() point INSIDE the
-- bundle -- not where the player drops portable.txt (next to the .app).
-- Recover the folder containing the .app so a packaged app finds its
+11 -105
View File
@@ -432,10 +432,6 @@ function TouchSkin.parseNative(text)
aspectFromCfg = raw.fitAspect == true,
orient = (raw.orient == "portrait" or raw.orient == "landscape"
or raw.orient == "any") and raw.orient or nil,
screenFit = raw.screenFit == "remainder" and "remainder" or nil,
anchor = (raw.anchor == "top" or raw.anchor == "bottom"
or raw.anchor == "left" or raw.anchor == "right")
and raw.anchor or nil,
rect = { x = 0, y = 0, w = 1, h = 1 },
controls = {},
}
@@ -511,8 +507,6 @@ function TouchSkin.toNative(skin)
alphaMod = page.alphaMod,
aspect = page.aspect,
fitAspect = page.aspectFromCfg or nil,
screenFit = page.screenFit == "remainder" and "remainder" or nil,
anchor = page.anchor,
orient = (page.orient == "portrait" or page.orient == "landscape"
or page.orient == "any") and page.orient or nil,
controls = {},
@@ -576,40 +570,6 @@ local function loadImage(path)
return img
end
-- FileData so LOVE sniffs JPEG/PNG from the name, not a path inside the zip.
local function loadImageFromBytes(bytes, name)
if not bytes or bytes == "" then return nil end
if not (love and love.graphics and love.graphics.newImage) then return nil end
if not (love.filesystem and love.filesystem.newFileData) then return nil end
local key = "bytes:" .. tostring(name) .. ":" .. tostring(#bytes)
local cached = imageCache[key]
if cached then return cached end
local okFd, fd = pcall(love.filesystem.newFileData, bytes, name or "bezel.jpg")
if not okFd or not fd then return nil end
local ok, img = pcall(love.graphics.newImage, fd)
if not ok or not img then
if love.image and love.image.newImageData then
local okData, data = pcall(love.image.newImageData, fd)
if okData and data then ok, img = pcall(love.graphics.newImage, data) end
end
end
if not ok or not img then return nil end
if img.setFilter then img:setFilter("linear", "linear") end
imageCache[key] = img
return img
end
local function rasterizePdfPage(page, root)
if not page or page.image or not page.pdfPath then return end
local pdf = readFile(joinPath(root, page.pdfPath))
local raster = require("src.core.PdfImage").extract(pdf)
if not raster then return end
local name = tostring(page.pdfPath):gsub("%.[Pp][Dd][Ff]$", "") .. "." .. raster.ext
page.rasterData = raster.data
page.rasterName = name:match("([^/]+)$") or name
page.image = loadImageFromBytes(raster.data, page.rasterName)
end
local function pixelScalePending(page)
if page.pixelCoords then return true end
for _, ctl in ipairs(page.controls or {}) do
@@ -662,8 +622,6 @@ function TouchSkin.load(root, id)
for _, page in ipairs(skin.pages) do
if page.imagePath then
page.image = loadImage(joinPath(root, page.imagePath))
elseif page.pdfPath then
rasterizePdfPage(page, root)
end
if not applyPixelScale(page) then
return nil, "could not read " .. tostring(page.imagePath)
@@ -688,7 +646,7 @@ end
TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true }
TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
TouchSkin.PDF_ONLY_MESSAGE =
"This skin uses PDF artwork with no extractable image. "
"This skin uses PDF artwork, which cannot be imported yet. "
.. "Ask the author for a PNG version."
function TouchSkin.archiveId(name)
@@ -1288,27 +1246,12 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
and page.aspect and page.aspect > 0 and h > 0
if fit then
local displayAspect = w / h
local anchor = page.anchor
if displayAspect > page.aspect then
bw = h * page.aspect
local extra = w - bw
if anchor == "right" then
bx = ox + extra
elseif anchor == "left" then
bx = ox
else
bx = ox + extra * 0.5
end
bx = ox + (w - bw) * 0.5
else
bh = w / page.aspect
local extra = h - bh
if anchor == "bottom" then
by = oy + extra
elseif anchor == "top" then
by = oy
else
by = oy + extra * 0.5
end
by = oy + (h - bh) * 0.5
end
end
local r = page.rect
@@ -1365,55 +1308,18 @@ end
function TouchSkin.hasViewport()
local page = TouchSkin.page()
if not page or not TouchSkin.drawable() then return false end
return page.viewport ~= nil or page.screenFit == "remainder"
end
-- Largest strip of (ox,oy,w,h) that does not overlap the overlay box.
local function remainderBox(ox, oy, w, h, bx, by, bw, bh)
local right, bottom = ox + w, oy + h
local cand = {
{ ox, oy, w, by - oy },
{ ox, by + bh, w, bottom - (by + bh) },
{ ox, oy, bx - ox, h },
{ bx + bw, oy, right - (bx + bw), h },
}
local best, bestArea
for _, r in ipairs(cand) do
if r[3] > 1 and r[4] > 1 then
local area = r[3] * r[4]
if not best or area > bestArea then
best, bestArea = r, area
end
end
end
if not best then return nil end
return best[1], best[2], best[3], best[4]
end
function TouchSkin.pageViewport(page, w, h, ox, oy)
if not page then return nil end
ox, oy = ox or 0, oy or 0
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
if page.viewport then
local v = page.viewport
local x, y = bx + v.x * bw, by + v.y * bh
local vw, vh = v.w * bw, v.h * bh
if vw <= 0 or vh <= 0 then return nil end
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
end
if page.screenFit == "remainder" then
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
if not x then return nil end
return x, y, vw, vh, false, false
end
return nil
return page ~= nil and page.viewport ~= nil and TouchSkin.drawable()
end
function TouchSkin.viewport(w, h, ox, oy)
local page = TouchSkin.page()
if not page or not TouchSkin.drawable() then return nil end
return TouchSkin.pageViewport(page, w, h, ox, oy)
if not page or not page.viewport or not TouchSkin.drawable() then return nil end
local v = page.viewport
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
local x, y = bx + v.x * bw, by + v.y * bh
local vw, vh = v.w * bw, v.h * bh
if vw <= 0 or vh <= 0 then return nil end
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
end
return TouchSkin
+27
View File
@@ -541,6 +541,29 @@ local function modRows(opts, mod)
return rows
end
local function troubleshootingRows(opts, hooks)
return {
{
label = Strings("SAFE MODE"),
actionLabel = function()
return SaveData.isSafeMode(opts) and Strings("Turn off") or Strings("Turn on")
end,
action = function()
SaveData.setSafeMode(opts, not SaveData.isSafeMode(opts))
return true
end,
},
{
label = Strings("REPORT ISSUE"),
actionLabel = Strings("Report bug"),
action = function()
if hooks and hooks.reportIssue then hooks.reportIssue(opts) end
return false
end,
},
}
end
-- ------- Gen 2 (Gold)
--
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
@@ -721,6 +744,10 @@ function LauncherSettings.open(hooks, version)
sections[#sections + 1] = { title = mod.name, rows = rows }
end
end
sections[#sections + 1] = {
title = Strings("TROUBLESHOOTING"),
rows = troubleshootingRows(opts, hooks),
}
return {
opts = opts,
version = version,
+39 -132
View File
@@ -406,15 +406,6 @@ local function cartQuad(project, x, y, w, h, z)
}
end
local function cartFacing(points)
local area = 0
for i = 1, #points do
local a, b = points[i], points[i % #points + 1]
area = area + a[1] * b[2] - b[1] * a[2]
end
return area > 0
end
local function cartPill(project, x, y, w, h, z, color, alpha)
local points, radius = {}, h / 2
for i = 0, 10 do
@@ -637,64 +628,51 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
local side = { math.floor(shell[1] * 0.54), math.floor(shell[2] * 0.54),
math.floor(shell[3] * 0.54) }
local frontFacing = cartFacing(mainFront)
if frontFacing then
cartPolygon(mainBack, side, 1)
cartPolygon(capBack, side, 1)
else
cartPolygon(mainFront, shell, 1)
cartPolygon(capFront, shell, 1)
end
cartPolygon(mainBack, side, 1)
cartPolygon(capBack, side, 1)
cartPolygon({ mainFront[2], mainFront[3], mainBack[3], mainBack[2] }, side, 1)
cartPolygon({ mainFront[3], mainFront[4], mainBack[4], mainBack[3] }, side, 1)
cartPolygon({ mainFront[1], mainFront[2], mainBack[2], mainBack[1] }, side, 1)
cartPolygon({ capFront[2], capFront[3], capBack[3], capBack[2] }, side, 1)
cartPolygon({ capFront[1], capFront[2], capBack[2], capBack[1] }, side, 1)
cartPolygon({ capFront[4], capFront[1], capBack[1], capBack[4] }, side, 1)
if frontFacing then
cartPolygon(mainFront, shell, 1)
cartPolygon(capFront, shell, 1)
else
cartPolygon(mainBack, side, 1)
cartPolygon(capBack, side, 1)
end
cartPolygon(mainFront, shell, 1)
cartPolygon(capFront, shell, 1)
if frontFacing then
local faceZ = depth + 0.8
for i = 0, 4 do
local ry = mainTop + 7 + i * h * 0.025
cartPolygon(cartQuad(project, -halfW + 2, ry, w * 0.13, 2, faceZ), side, 0.7)
cartPolygon(cartQuad(project, halfW - w * 0.13 - 2, ry, w * 0.13, 2, faceZ), side, 0.7)
end
local recessX, recessY = -w * 0.32, mainTop + h * 0.023
local recessW, recessH = w * 0.64, h * 0.24
cartPolygon(cartQuad(project, recessX, recessY, recessW, recessH, faceZ), shell, 0.88)
cartPill(project, recessX + w * 0.025, recessY + h * 0.025,
recessW - w * 0.05, h * 0.12, faceZ + 0.5, shell, 0.7)
cartPill(project, recessX + w * 0.045, recessY + h * 0.043,
recessW - w * 0.09, h * 0.083, faceZ + 0.8, side, 0.42)
local labelX, labelY = -w * 0.33, -h * 0.20
local labelW, labelH = w * 0.66, h * 0.55
local plate = cartQuad(project, labelX - 2, labelY - 2, labelW + 4, labelH + 4, faceZ + 0.8)
cartPolygon(plate, side, 0.95)
local labelPoints = cartQuad(project, labelX, labelY, labelW, labelH, faceZ + 1.2)
local label = cartridgeLabel(imp, version)
local mesh = label and cartLabelMesh(imp, version, label, labelPoints)
if mesh then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(mesh)
elseif label then
local artScale = math.min(labelW / label.width, labelH / label.height)
love.graphics.draw(label.image, labelPoints[1][1], labelPoints[1][2],
0, artScale, artScale)
end
cartPolygon({
{ project(-w * 0.07, h * 0.37, faceZ + 1) },
{ project(w * 0.07, h * 0.37, faceZ + 1) },
{ project(0, h * 0.43, faceZ + 1) },
}, side, 0.70)
local faceZ = depth + 0.8
for i = 0, 4 do
local ry = mainTop + 7 + i * h * 0.025
cartPolygon(cartQuad(project, -halfW + 2, ry, w * 0.13, 2, faceZ), side, 0.7)
cartPolygon(cartQuad(project, halfW - w * 0.13 - 2, ry, w * 0.13, 2, faceZ), side, 0.7)
end
local recessX, recessY = -w * 0.32, mainTop + h * 0.023
local recessW, recessH = w * 0.64, h * 0.24
cartPolygon(cartQuad(project, recessX, recessY, recessW, recessH, faceZ), shell, 0.88)
cartPill(project, recessX + w * 0.025, recessY + h * 0.025,
recessW - w * 0.05, h * 0.12, faceZ + 0.5, shell, 0.7)
cartPill(project, recessX + w * 0.045, recessY + h * 0.043,
recessW - w * 0.09, h * 0.083, faceZ + 0.8, side, 0.42)
local labelX, labelY = -w * 0.33, -h * 0.20
local labelW, labelH = w * 0.66, h * 0.55
local plate = cartQuad(project, labelX - 2, labelY - 2, labelW + 4, labelH + 4, faceZ + 0.8)
cartPolygon(plate, side, 0.95)
local labelPoints = cartQuad(project, labelX, labelY, labelW, labelH, faceZ + 1.2)
local label = cartridgeLabel(imp, version)
local mesh = label and cartLabelMesh(imp, version, label, labelPoints)
if mesh then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(mesh)
elseif label then
local artScale = math.min(labelW / label.width, labelH / label.height)
love.graphics.draw(label.image, labelPoints[1][1], labelPoints[1][2],
0, artScale, artScale)
end
cartPolygon({
{ project(-w * 0.07, h * 0.37, faceZ + 1) },
{ project(w * 0.07, h * 0.37, faceZ + 1) },
{ project(0, h * 0.43, faceZ + 1) },
}, side, 0.70)
love.graphics.pop()
if not state.active and (Kit._activateId == key) then
@@ -948,13 +926,10 @@ local HEADER_TABS = {
{ id = "mods", key = "tab-mods" },
{ id = "find", key = "tab-find" },
{ id = "skins", key = "tab-skins", glyph = true },
{ id = "bug", key = "tab-bug" },
}
for _, t in ipairs(HEADER_TABS) do
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
if t.glyph then
t.opts.drawFn = drawSkinGlyph
end
if t.glyph then t.opts.drawFn = drawSkinGlyph end
end
-- Which cartridge the dropdown is showing: the open game tab, else the last
@@ -1092,8 +1067,6 @@ local function buildHeader(imp, m)
or love.graphics.newImage("assets/launcher/mods.png")
imp._findIcon = imp._findIcon
or love.graphics.newImage("assets/launcher/find.png")
imp._bugIcon = imp._bugIcon
or love.graphics.newImage("assets/launcher/bug.png")
-- Game tabs keep their cartridge colours -- that is the one piece of brand
-- identity in the launcher, and "the red one" is how people actually refer
-- to these. The colour rides the outline and the glyph at rest and becomes
@@ -1104,7 +1077,6 @@ local function buildHeader(imp, m)
for _, t in ipairs(tabs) do
if t.id == "mods" then t.icon = imp._modsIcon end
if t.id == "find" then t.icon = imp._findIcon end
if t.id == "bug" then t.icon = imp._bugIcon end
end
local tabH = m.chip
local tx = m.x + m.pad
@@ -1951,7 +1923,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
-- notice line
local noticeText, noticeCol
if safeMode then
noticeText, noticeCol = "Safe mode is on. All mods are disabled. Turn it off in the Bug tab to change mod toggles.", PAL.yellow
noticeText, noticeCol = "Safe mode is on. All mods are disabled. Turn it off in Settings to change mod toggles.", PAL.yellow
elseif imp.modNotice then
noticeText = imp.modNotice.text
noticeCol = imp.modNotice.ok and PAL.green or PAL.red
@@ -2350,69 +2322,6 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
return cy + hintH - y
end
local function buildBugPanel(imp, x, y, w, availH, m)
local SaveData = require("src.core.SaveData")
local gap = m.gap
local pad = math.floor(16 * m.s)
local cy = y
local safeMode = imp:_safeModeEnabled()
Kit.text("button", Strings("Troubleshooting"), x, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + gap
if imp.issueNotice then
cy = cy + Kit.textWrapped("small", imp.issueNotice.text, x, cy, w,
imp.issueNotice.ok and PAL.green or PAL.red, 2) + gap
end
local switchW = math.floor(92 * m.s)
local switchH = math.max(m.btnH, Kit.tapMin())
local detail = safeMode
and Strings("All mods are disabled and their toggles are locked until safe mode is turned off.")
or Strings("Temporarily disable every mod while you reproduce a bug.")
local textW = math.max(0, w - 2 * pad - switchW - gap)
local detailH = Kit.wrapHeight("small", detail, textW, 3)
local safeH = math.max(switchH, Kit.textHeight("small") + math.floor(4 * m.s) + detailH)
+ 2 * pad
Kit.card(x, cy, w, safeH)
local textX = x + pad
local textY = cy + pad
Kit.text("small", Strings("Safe mode"), textX, textY, PAL.heading)
Kit.textWrapped("small", detail, textX,
textY + Kit.textHeight("small") + math.floor(4 * m.s), textW,
PAL.muted, 3)
local toggleX = x + w - pad - switchW
local toggleY = cy + math.floor((safeH - switchH) / 2)
local _, changed = Kit.toggle(toggleX, toggleY, switchW, switchH, safeMode,
"bug-safe-mode")
if changed then
queueAction(imp, "bug-safe-mode", function() imp:_toggleSafeMode() end)
end
cy = cy + safeH + gap
local reportLabel = Strings("Report a bug")
local reportW = math.min(w - 2 * pad,
Kit.textWidth("small", reportLabel) + math.floor(32 * m.s))
local reportDetail = Strings("Fill out the GitHub form with the available system information.")
local reportTextW = math.max(0, w - 2 * pad - reportW - gap)
local reportDetailH = Kit.wrapHeight("small", reportDetail, reportTextW, 3)
local reportH = math.max(m.btnH, Kit.textHeight("small") + math.floor(4 * m.s) + reportDetailH)
+ 2 * pad
Kit.card(x, cy, w, reportH)
Kit.text("small", Strings("Something not working?"), textX, cy + pad, PAL.heading)
Kit.textWrapped("small", reportDetail, textX,
cy + pad + Kit.textHeight("small") + math.floor(4 * m.s), reportTextW,
PAL.muted, 3)
btn(imp, x + w - pad - reportW,
cy + math.floor((reportH - m.btnH) / 2), reportW, m.btnH,
"bug-report", reportLabel, {
kind = "accent", font = "small",
action = function()
imp:_ensureMods()
imp:_reportIssue(SaveData.loadOptions(), nil)
end })
end
local function buildFindPanel(imp, x, y, w, availH, m)
imp:_ensureFind()
imp:_ensureMods()
@@ -4913,8 +4822,6 @@ function LauncherView.draw(imp)
contentH = buildFindPanel(imp, x, py, panelW, budgetH, m)
elseif imp.tab == "skins" then
contentH = buildSkinsPanel(imp, x, py, panelW, budgetH, m)
elseif imp.tab == "bug" then
contentH = buildBugPanel(imp, x, py, panelW, budgetH, m)
else
contentH = buildGamePanel(imp, x, py, panelW, availH, m, imp.tab, budgetH)
end
+21 -34
View File
@@ -1266,7 +1266,6 @@ end
function RomImporter:_applyLastVersionTab()
local okLO, LO = pcall(require, "src.core.LaunchOptions")
if okLO and LO.pendingTab then return end
if os.getenv("POKEPORT_LAUNCHER_TAB") then return end
local okOpt, opts = pcall(function()
return require("src.core.SaveData").loadOptions()
end)
@@ -1338,7 +1337,7 @@ function RomImporter.new(onComplete, opts)
-- player at least arrives on the tab they asked for (src/core/LaunchOptions).
tab = (function()
local okLO, LO = pcall(require, "src.core.LaunchOptions")
return (okLO and LO.pendingTab) or os.getenv("POKEPORT_LAUNCHER_TAB") or "red"
return (okLO and LO.pendingTab) or "red"
end)(),
logo = love.graphics.newImage("assets/logo/logo.png"),
bcg = love.graphics.newImage("assets/logo/bcg.png"),
@@ -1365,8 +1364,7 @@ function RomImporter.new(onComplete, opts)
-- in draw); modNotice is the last install/delete result { ok, text }.
-- requiredImportNotice stays inside the imported-files modal so validation
-- failures are visible beside the file picker that caused them.
mods = nil, modScroll = 0, modNotice = nil, issueNotice = nil,
requiredImportNotice = nil,
mods = nil, modScroll = 0, modNotice = nil, requiredImportNotice = nil,
-- Which game the MODS panel is answering for (a GameVersion id, nil =
-- every game). Rows resolve their enable-state and their "runs here"
-- verdict against it (src/mods/ModTargets.lua).
@@ -2757,7 +2755,7 @@ function RomImporter:resumeAfterOverlay()
end
function RomImporter:_cycleTab(delta)
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins", "bug" }
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins" }
local idx = 1
for i, id in ipairs(order) do
if id == self.tab then idx = i; break end
@@ -3152,8 +3150,7 @@ function RomImporter:_ensureSkins(force)
format = skin and skin.format or nil,
pages = skin and #skin.pages or 0,
controls = controls,
screen = page ~= nil
and (page.viewport ~= nil or page.screenFit == "remainder"),
screen = page ~= nil and page.viewport ~= nil,
ok = skin ~= nil,
}
end
@@ -3632,6 +3629,9 @@ function RomImporter:_openSettings()
-- option block, and Gold's is not the flat Gen 1 one (#1100).
local hooks = {}
local version = self.tab
hooks.reportIssue = function(opts)
return self:_reportIssue(opts, version)
end
if self.onEditTouchControls then
local version = self.tab
hooks.editTouchControls = function()
@@ -3654,6 +3654,7 @@ function RomImporter:_openSettings()
end)
if ok and model then
self._settings = model
self._settingsSafeModeAtOpen = require("src.core.SaveData").isSafeMode(model.opts)
end
end
@@ -3668,36 +3669,22 @@ function RomImporter:_closeSettings()
local model = self._settings
if model then
model.save()
local safeMode = require("src.core.SaveData").isSafeMode(model.opts)
if safeMode ~= self._settingsSafeModeAtOpen then
self.mods = nil
self.safeMode = safeMode
self._modSortCache = nil
self._modInfoFetch = nil
end
end
self._settings = nil
end
function RomImporter:_safeModeEnabled()
if self.safeMode == nil then
local SaveData = require("src.core.SaveData")
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
end
return self.safeMode == true
end
function RomImporter:_toggleSafeMode()
local SaveData = require("src.core.SaveData")
local options = SaveData.loadOptions()
local enabled = not SaveData.isSafeMode(options)
SaveData.setSafeMode(options, enabled)
SaveData.saveOptions(options)
self.safeMode = enabled
self.mods = nil
self._modSortCache = nil
self._modInfoFetch = nil
self.modNotice = nil
self._settingsSafeModeAtOpen = nil
end
function RomImporter:_reportIssue(options, version)
self.issueNotice = nil
local ok, IssueReport = pcall(require, "src.core.IssueReport")
if not ok then
self.issueNotice = { ok = false, text = "Could not prepare the issue report." }
self.modNotice = { ok = false, text = "Could not prepare the issue report." }
return false
end
local opened, url, reason = IssueReport.open(options, {
@@ -3705,11 +3692,11 @@ function RomImporter:_reportIssue(options, version)
mods = self.mods,
})
if not opened then
self.issueNotice = { ok = false, text = reason or "Could not open the issue report." }
self.modNotice = { ok = false, text = reason or "Could not open the issue report." }
return false
end
self._lastIssueReportURL = url
if reason then self.issueNotice = { ok = true, text = reason } end
if reason then self.modNotice = { ok = true, text = reason } end
return true
end
@@ -4192,7 +4179,7 @@ end
-- Enabling an experimental mod arms a confirmation for that same game.
function RomImporter:_toggleMod(id, confirmed, version)
if self.safeMode then
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." }
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." }
return
end
local LauncherMods = require("src.mods.LauncherMods")
@@ -4236,7 +4223,7 @@ end
-- recovery action, and Delete is the only destructive one on this panel.
function RomImporter:_setAllMods(want, confirmed)
if self.safeMode then
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." }
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." }
return
end
local LauncherMods = require("src.mods.LauncherMods")
-13
View File
@@ -57,8 +57,6 @@ function TextBox.new(game, text, onDone, opts)
self.money = opts and opts.money
self.auto = opts and opts.auto
self.stay = opts and opts.stay
-- engine/events/hidden_events/cinnabar_gym_quiz.asm:119
self.preSound = opts and opts.preSound
-- 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
@@ -272,17 +270,6 @@ end
function TextBox:update(dt)
local input = self.game.input
self.blink = (self.blink + 1) % 60
-- home/text.asm:506
if self.preSound then
if not self.preStarted then
self.preStarted = true
self.preSrc = self.preSound()
end
if self.preSrc and self.preSrc.isPlaying and self.preSrc:isPlaying() then
return
end
self.preSound, self.preSrc = nil, nil
end
-- A page or CONT advance blocks the whole box while the original's scroll
-- and clear run (src/core/Timing.lua TEXT_SCROLL_PAIR / TEXT_PAGE_CLEAR).
-- Nothing types and no input is read until it drains.
+1 -2
View File
@@ -254,8 +254,7 @@ function Commands.give_item(ctx, itemId, count, gotText)
Commands.show_text(ctx, gotText
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
else
-- scripts/OaksLab.asm:1058
Commands.text_sound(ctx, jingle)
Sound.play(ctx.game.data, jingle)
end
end
+11 -13
View File
@@ -851,7 +851,6 @@ function Studio.detectViewport()
end
Studio.pushUndo()
page.viewport = rect
page.screenFit = nil
setStatus(("Screen detected: %dx%d px in the bezel art"):format(pw, ph))
Studio.dirty = true
end
@@ -861,9 +860,8 @@ function Studio.toggleViewport()
if not page then return end
if Studio.canvas().lockViewport then return end
Studio.pushUndo()
if page.viewport or page.screenFit == "remainder" then
if page.viewport then
page.viewport = nil
page.screenFit = nil
else
page.viewport = { x = 0.1, y = 0.05, w = 0.8, h = 0.45 }
end
@@ -894,7 +892,7 @@ function Studio.pageLabel(index)
local orient = TouchSkin.pageOrient(page)
local bits = { #(page.controls or {}) .. " controls" }
if orient then bits[#bits + 1] = orient end
if page.viewport or page.screenFit == "remainder" then bits[#bits + 1] = "screen" end
if page.viewport then bits[#bits + 1] = "screen" end
return name, table.concat(bits, " \194\183 ")
end
@@ -1094,9 +1092,10 @@ function Studio.snapLines(page, r, skipIndex)
end
local function viewportRect(page, r)
local x, y, w, h = TouchSkin.pageViewport(page, r.w, r.h, r.x, r.y)
if not x then return nil end
return x, y, w, h
local v = page.viewport
if not v then return nil end
local bx, by, bw, bh = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y)
return bx + v.x * bw, by + v.y * bh, v.w * bw, v.h * bh
end
local function handleRects(bx, by, bw, bh)
@@ -1139,7 +1138,7 @@ function Studio.beginCanvasDrag(mx, my, r)
end
local vx, vy, vw, vh = viewportRect(page, r)
if vx and not Studio.canvas().lockViewport and page.screenFit ~= "remainder" then
if vx and not Studio.canvas().lockViewport then
for _, h in ipairs(handleRects(vx, vy, vw, vh)) do
if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then
Studio.pushUndo()
@@ -1163,7 +1162,7 @@ function Studio.beginCanvasDrag(mx, my, r)
end
end
if vx and not Studio.canvas().lockViewport and page.screenFit ~= "remainder"
if vx and not Studio.canvas().lockViewport
and mx >= vx and mx <= vx + vw and my >= vy and my <= vy + vh then
Studio.selected = nil
Studio.pushUndo()
@@ -1271,7 +1270,7 @@ local function drawCanvas(x, y, w, h)
if vx then
Theme.strokeRounded(vx, vy, vw, vh, PAL.blue, 0.9, 2, 2)
Kit.text("small", "SCREEN", vx + 4 * Kit.scale, vy + 4 * Kit.scale, PAL.blue)
if not Studio.canvas().lockViewport and page.screenFit ~= "remainder" then
if not Studio.canvas().lockViewport then
local a = Studio.selectedControl() and 0.45 or 1
for _, hd in ipairs(handleRects(vx, vy, vw, vh)) do
Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.blue, a)
@@ -1375,7 +1374,7 @@ local function inspectorBody(x, y, w)
cy = cy + rowH + gap
if page then
local bezel = page.imagePath or page.rasterName or "(none)"
local bezel = page.imagePath or "(none)"
local pickW = 82 * Kit.scale
local cycleW = w - pickW - gap
if Kit.button(x, cy, cycleW, rowH, "Bezel: " .. bezel, { id = "bezel" }) then
@@ -1386,8 +1385,7 @@ local function inspectorBody(x, y, w)
Studio.importImageFile("bezel")
end
cy = cy + rowH + gap
local vpLabel = (page.viewport or page.screenFit == "remainder")
and "Screen cutout: ON" or "Screen cutout: OFF"
local vpLabel = page.viewport and "Screen cutout: ON" or "Screen cutout: OFF"
if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp",
enabled = not Studio.canvas().lockViewport }) then
Studio.toggleViewport()
+22 -1
View File
@@ -44,6 +44,12 @@ local BattleState = {}
BattleState.__index = BattleState
BattleState.isOpaque = true
function BattleState:moveGridNavigation()
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
return Runtime.call("battle.move_grid_navigation", function() return false end,
self) == true
end
-- Armed while a battle line waits for PromptButton (home/text.asm). Any
-- positive value means "hold until A/B"; the cart never times these out, so
-- the victory jingle can keep looping through the post-win prompts.
@@ -2044,7 +2050,22 @@ function BattleState:update(_dt)
if self.phase == "moves" then
local moves = self:playerMoves()
if input:wasPressed("up") then
local grid
if self:moveGridNavigation() then
local index, count = self.moveIndex, #moves
if input:wasPressed("left") or input:wasPressed("right") then
local other = math.floor((index - 1) / 2) * 2
+ (1 - (index - 1) % 2) + 1
grid = other <= count and other or index
elseif input:wasPressed("up") or input:wasPressed("down") then
local other = (1 - math.floor((index - 1) / 2)) * 2
+ (index - 1) % 2 + 1
grid = other <= count and other or index
end
end
if grid then
self.moveIndex = grid
elseif input:wasPressed("up") then
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
elseif input:wasPressed("down") then
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
+3 -24
View File
@@ -537,15 +537,6 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
-- Route22Gate_Script rewrites wLastMap from the player's Y on entry
-- too (not only on step), so a save/load mid-gate keeps exits correct
if not (opts and opts.checkpoint) then self:syncLastMapRewrite() end
-- home/overworld.asm:1821 (JoypadOverworld runs RunMapScript every frame);
-- scripts/Route16Gate1F.asm:16, Route5Gate.asm:19, Route22Gate.asm:21
if opts and opts.freshBoot and not opts.checkpoint then
local standing = mapScripts and mapScripts.get(mapId)
if not (standing and standing.onStep
and standing.onStep(Game, self, self.player.cellX, self.player.cellY)) then
self:checkBadgeGate()
end
end
end
-- Neighbor maps drawn at the composed connection offsets: at least the
@@ -2919,15 +2910,7 @@ function OverworldState:openPC(onDone)
keepOpen = true,
onSelect = function()
require("src.core.Sound").play(Game.data, "Enter_PC")
-- engine/menus/pc.asm:73 BillsPC prints the access text before the farcall
local accessed = metBill
and romText(Game.data, "_AccessedBillsPCText",
"Accessed BILL's\nPC.\fAccessed POKéMON\nStorage System.")
or romText(Game.data, "_AccessedSomeonesPCText",
"Accessed someone's\nPC.\fAccessed POKéMON\nStorage System.")
Game.stack:push(TextBox.new(Game, accessed, function()
Screens.push(Game, "BoxMenu")
end))
Screens.push(Game, "BoxMenu")
done()
end,
})
@@ -2937,13 +2920,9 @@ function OverworldState:openPC(onDone)
label = (Game.save.player.name or "RED") .. "'s PC",
keepOpen = true,
onSelect = function()
-- pc.asm .playersPC plays SFX_ENTER_PC then prints AccessedMyPCText
-- before the farcall (engine/menus/pc.asm:54, #960)
-- pc.asm .playersPC plays SFX_ENTER_PC before the farcall (#960)
require("src.core.Sound").play(Game.data, "Enter_PC")
Game.stack:push(TextBox.new(Game,
romText(Game.data, "_AccessedMyPCText",
"Accessed my PC.\fAccessed Item\nStorage System."),
function() Screens.push(Game, "PlayerPC") end))
Screens.push(Game, "PlayerPC")
done()
end,
})
-4
View File
@@ -126,10 +126,6 @@ local PLAYER_STATE_BY_ID = {
[8] = FieldMoves.PLAYER_SURF_PIKA,
}
-- engine/overworld/variables.asm:49 VAR_MOVEMENT reads wPlayerState back
local PLAYER_STATE_ID = {}
for id, state in pairs(PLAYER_STATE_BY_ID) do PLAYER_STATE_ID[state] = id end
local BATTLETYPE = {
CANLOSE = 1,
FORCESHINY = 7,
@@ -1,50 +0,0 @@
-- The bike shop's "A shiny new BICYCLE!" is six hidden_event rows, not a
-- bg_event: data/events/hidden_events.asm:542-549 points every one of them
-- at PrintNewBikeText (engine/events/hidden_events/new_bike.asm:1), which
-- tx_pre_jumps NewBicycleText with ANY_FACING and no gating. The port's
-- field extractor lifts none of that family, so BIKE_SHOP had no display
-- text at all (#1530).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local pushed
package.loaded["src.render.TextBox"] = {
new = function(_, text, onDone) return { text = text, onDone = onDone } end,
}
local scripts = require("data.scripts.flavor.bike_shop")
local onInteract = scripts.BIKE_SHOP.onInteract
T.check(type(onInteract) == "function", "BIKE_SHOP carries an onInteract hook")
local game = {
data = { text = { _NewBicycleText = "A shiny new\nBICYCLE!" } },
stack = { push = function(_, box) pushed = box end },
}
-- data/events/hidden_events.asm:543-548 (the macro emits y then x, so the
-- source pairs read x, y)
for _, cell in ipairs({ { 1, 0 }, { 2, 1 }, { 1, 2 }, { 3, 2 }, { 0, 4 }, { 1, 5 } }) do
pushed = nil
local consumed = onInteract(game, {}, cell[1], cell[2])
T.eq(consumed, true, ("(%d,%d) is a display tile"):format(cell[1], cell[2]))
T.check(pushed ~= nil and pushed.text == game.data.text._NewBicycleText,
("(%d,%d) prints _NewBicycleText"):format(cell[1], cell[2]))
end
-- data/generated/maps.lua BIKE_SHOP object_events sit at (6,2), (5,6) and
-- (1,3): none of the six, so the hook never steals an NPC's talk
for _, cell in ipairs({ { 4, 4 }, { 6, 2 }, { 5, 6 }, { 1, 3 }, { 0, 0 } }) do
pushed = nil
local consumed = onInteract(game, {}, cell[1], cell[2])
T.eq(consumed, false, ("(%d,%d) is not a display tile"):format(cell[1], cell[2]))
T.eq(pushed, nil, "and pushes nothing")
end
-- with no cache text the hook still prints the line
pushed = nil
onInteract({ data = {}, stack = game.stack }, {}, 1, 0)
T.check(pushed ~= nil and pushed.text:find("BICYCLE", 1, true) ~= nil,
"a dataset without the label falls back to the engine wording")
T.finish("bike shop display text (#1530)")
+1
View File
@@ -422,6 +422,7 @@ local GEN2_HOOKS = {
-- nextFn gets nil there).
"battle.catch_exp", "battle.low_health_alarm", "battle.overlay",
"battle.bottom_ui_visible", "battle.status_hud_visible",
"battle.move_grid_navigation",
-- One pic path resolver for both games: the Gen 1 site is the SHARED
-- src/pokemon/Sprites.lua and Gold's own battle screen calls the same hook
-- with the Gen 1 ctx keys plus `letter` and `shiny`, which Red has no
+3 -25
View File
@@ -146,34 +146,12 @@ T.eq(resumed, 1, "the script resumed once")
-- ------------------------------------------------------------- plain gift
-- gotText == false is the script-shows-its-own-text form (Oak's 5 POKE
-- BALLs): the received text the script prints next carries the jingle
-- (scripts/OaksLab.asm:1058-1062), so give_item arms it and plays nothing
-- BALLs): nothing to hang the sound on, so it plays on the spot
plays = {}
Commands.give_item(ctx, "FIX_POTION", 1, false)
T.eq(jingles(), 0, "the no-text gift plays nothing on the spot")
T.eq(jingles(), 1, "the no-text gift still plays its jingle immediately")
T.eq(plays[1], "item.wav", "with the plain-item sound")
T.eq(stack:top(), nil, "and pushes no box of its own")
T.check(ctx.textOpts and ctx.textOpts.auto and ctx.textOpts.auto.sound ~= nil,
"the jingle is armed for the next show_text")
Commands.show_text(ctx, "{PLAYER} GOT\nSOMETHING!")
local box2 = stack:top()
T.check(getmetatable(box2) == TextBox, "the script's own received box is up")
T.eq(ctx.textOpts, nil, "show_text consumed the armed jingle")
for _ = 1, 2000 do
if box2.done then break end
step(box2.waiting and "a" or nil)
end
T.check(box2.done, "the received text typed out")
T.eq(jingles(), 0, "silent until the last character is placed")
step()
T.eq(jingles(), 1, "the jingle fires once the text is out")
T.eq(plays[#plays], "item.wav", "with the plain-item sound")
step("a")
T.eq(stack:top(), box2, "A does not close the box during the jingle")
sources["item.wav"].playing = false
step()
step("a")
T.eq(stack:top(), nil, "A closes the box after the jingle")
-- ------------------------------------------------------------ script data
-- both Viridian Mart paths must hand give_item the quest text, since that
@@ -29,12 +29,4 @@ check(patch:find("int w_pickFileKinds", 1, true)
check(patch:find('("GRPickerBridge.swift", ID_FILE_PICKER', 1, true),
"iOS build patch compiles the required-import picker bridge")
local bootstrap = read("mobile/ios/native/GRBootstrap.m")
check(bootstrap:find("struct utsname", 1, true)
and bootstrap:find("SIMULATOR_MODEL_IDENTIFIER", 1, true),
"iOS native bridge reads the hardware model on device and simulator")
check(patch:find("int w_getDeviceModel", 1, true)
and patch:find('{ "getDeviceModel", w_getDeviceModel }', 1, true),
"iOS liblove patch exposes the hardware model to Lua")
print("ios_required_import_picker_test: ok")
-21
View File
@@ -50,34 +50,20 @@ local imp = read("src/import/RomImporter.lua")
check(view:find('id = "skins"', 1, true) ~= nil,
"LauncherView registers a skins tab")
check(view:find('id = "bug"', 1, true) ~= nil,
"LauncherView registers a bug tab")
check(view:find("drawSkinGlyph", 1, true) ~= nil,
"the skins tab draws its own glyph rather than shipping an asset")
check(view:find('assets/launcher/bug.png', 1, true) ~= nil,
"the bug tab uses the standard bug report asset")
-- the tab has to be next to Find, which is what the request was
local order = view:match("local HEADER_TABS = %{(.-)%}\n")
check(order ~= nil, "HEADER_TABS found")
if order then
local findAt = order:find('id = "find"', 1, true)
local skinsAt = order:find('id = "skins"', 1, true)
local bugAt = order:find('id = "bug"', 1, true)
check(findAt and skinsAt and skinsAt > findAt,
"the skins tab sits immediately after Find")
check(skinsAt and bugAt and bugAt > skinsAt,
"the bug tab sits after Skins")
end
check(view:find('imp.tab == "skins"', 1, true) ~= nil,
"the panel dispatch routes the skins tab")
check(view:find("buildSkinsPanel", 1, true) ~= nil, "and a panel builds it")
check(view:find('imp.tab == "bug"', 1, true) ~= nil,
"the panel dispatch routes the bug tab")
check(view:find("buildBugPanel", 1, true) ~= nil, "and the bug panel builds it")
check(view:find('Kit.toggle', 1, true) ~= nil,
"the bug panel uses a switch for safe mode")
check(view:find('bug-report', 1, true) ~= nil,
"the bug panel has a report action")
-- the panel must not offer the studio when the host did not supply it
check(view:find("if imp.onOpenSkinStudio then", 1, true) ~= nil,
"the Studio button is hidden without a host hook (mobile)")
@@ -93,15 +79,8 @@ check(imp:find("_installMod", 1, true) ~= nil,
local cycle = imp:match("local order = %{(.-)%}")
check(cycle and cycle:find('"skins"', 1, true) ~= nil,
"shoulder-button tab cycling reaches the skins tab")
check(cycle and cycle:find('"bug"', 1, true) ~= nil,
"shoulder-button tab cycling reaches the bug tab")
local switch = imp:match("function RomImporter:_switchTab%(id%)(.-)\nend")
check(switch and switch:find("_ensureSkins(true)", 1, true) ~= nil,
"switching to the tab re-reads the skin list")
check(imp:find('function RomImporter:_safeModeEnabled', 1, true) ~= nil
and imp:find('function RomImporter:_toggleSafeMode', 1, true) ~= nil,
"the importer owns the safe mode state")
check(imp:find('function RomImporter:_reportIssue', 1, true) ~= nil,
"the importer owns issue report opening")
T.finish("launcher_skins_tab")
+1 -1
View File
@@ -15,7 +15,7 @@ love = love or require("tests.love_stub")
-- scripts/build.sh mac layout: the game is an archive inside the .app, the
-- player's portable folder is the one holding the .app.
local PORTABLE = "/Users/p/Games"
local APP = PORTABLE .. "/gen1recomp++.app"
local APP = PORTABLE .. "/gen1recomp.app"
local SOURCE = APP .. "/Contents/Resources/game.love"
local BASE = APP .. "/Contents/MacOS"
-100
View File
@@ -1,100 +0,0 @@
-- engine/menus/pc.asm prints the access text between SFX_ENTER_PC and the
-- farcall: BillsPC (:73-85) picks AccessedBillsPCText / AccessedSomeonesPCText
-- off EVENT_MET_BILL, .playersPC (:50-59) prints AccessedMyPCText. The port
-- opened BoxMenu / PlayerPC straight from the sound (#1529).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
Data.text._TurnedOnPC1Text = "{PLAYER} turned on\nthe PC."
Data.text._AccessedBillsPCText = "Accessed BILL's\nPC.\fAccessed POKéMON\nStorage System."
Data.text._AccessedSomeonesPCText =
"Accessed someone's\nPC.\fAccessed POKéMON\nStorage System."
Data.text._AccessedMyPCText = "Accessed my PC.\fAccessed Item\nStorage System."
local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed, screens = {}, {}
local stackStub = { push = function(_, item) pushed[#pushed + 1] = item end }
local textBoxStub = {
new = function(_, text, onDone, opts)
return { kind = "text", text = text, onDone = onDone, opts = opts }
end,
}
local menuStub = {
new = function(_, items, opts) return { kind = "menu", items = items, opts = opts or {} } end,
}
package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end }
package.loaded["src.ui.Menu"] = menuStub
local fakeGame = { data = Data, save = SaveData.newGame(), stack = stackStub }
T.check(setUpvalue(OW.openPC, "Game", fakeGame), "Game upvalue on openPC")
T.check(setUpvalue(OW.openPC, "TextBox", textBoxStub), "TextBox upvalue on openPC")
T.check(setUpvalue(OW.openPC, "Screens",
{ push = function(_, id) screens[#screens + 1] = id end }), "Screens upvalue on openPC")
local fakeSelf = setmetatable({}, { __index = OW })
local function openMenu(metBill)
pushed, screens = {}, {}
fakeGame.save = SaveData.newGame()
if metBill then fakeGame.save.flags.EVENT_MET_BILL = true end
fakeSelf:openPC(function() end)
local pcOn = pushed[#pushed]
T.eq(pcOn.kind, "text", "the session opens with TurnedOnPC1Text")
pcOn.onDone()
local menu = pushed[#pushed]
T.eq(menu.kind, "menu", "then the PC menu")
return menu
end
-- === SOMEONE'S PC: the access text before BoxMenu
do
local menu = openMenu(false)
menu.items[1].onSelect()
local box = pushed[#pushed]
T.eq(box.kind, "text", "the box row opens a text box")
T.check(tostring(box.text):find("Accessed someone's", 1, true) ~= nil,
"before meeting BILL it is AccessedSomeonesPCText")
T.eq(#screens, 0, "and the box screen has NOT opened yet")
box.onDone()
T.eq(screens[1], "BoxMenu", "BoxMenu follows the text, as the farcall does")
end
-- === BILL'S PC once EVENT_MET_BILL is set
do
local menu = openMenu(true)
menu.items[1].onSelect()
local box = pushed[#pushed]
T.check(tostring(box.text):find("Accessed BILL's", 1, true) ~= nil,
"after meeting BILL it is AccessedBillsPCText")
box.onDone()
T.eq(screens[1], "BoxMenu", "and still opens BoxMenu")
end
-- === the player's item storage
do
local menu = openMenu(false)
menu.items[2].onSelect()
local box = pushed[#pushed]
T.eq(box.kind, "text", "the item row opens a text box")
T.check(tostring(box.text):find("Accessed my PC.", 1, true) ~= nil,
"and it is AccessedMyPCText")
T.eq(#screens, 0, "PlayerPC has not opened yet")
box.onDone()
T.eq(screens[1], "PlayerPC", "PlayerPC follows the text")
end
T.finish("PC access text (#1529)")
@@ -1,152 +0,0 @@
-- A landed secondary POISON runs PoisonEffect's tail
-- (engine/battle/effects.asm:119-151): SHAKE_SCREEN_ANIM when the foe
-- poisoned you, ENEMY_HUD_SHAKE_ANIM when you poisoned the foe, through
-- PlayBattleAnimation2 (:1461-1471), which also stamps wAnimationType 6 /
-- 3 so the slow applying shake runs even with battle animations off.
-- FreezeBurnParalyzeEffect (:194-255) zeroes wAnimationType and only
-- shakes the enemy HUD on the player's turn. The port queued neither
-- (#1526).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
Data.moves.FIX_POISON_STING = {
id = "FIX_POISON_STING", index = 5, name = "FIX PSN STING", type = "POISON",
power = 15, accuracy = 100, pp = 35, effect = "POISON_SIDE_EFFECT1",
}
local function newBattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 40) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 40)
battle.rng = function() return 0 end -- every roll lands
return battle
end
local function animRows(battle)
local rows = {}
for _, item in ipairs(battle.queue) do
if item.anim then rows[#rows + 1] = item end
end
return rows
end
local function find(rows, name)
for i, row in ipairs(rows) do
if row.anim == name then return i, row end
end
return nil
end
local function textIndex(battle, needle)
for i, item in ipairs(battle.queue) do
if item.text and item.text:find(needle, 1, true) then return i end
end
return nil
end
local function animIndex(battle, name)
for i, item in ipairs(battle.queue) do
if item.anim == name then return i end
end
return nil
end
-- ---------------------------------------------------------------------
-- the foe poisons you: SE_SHAKE_SCREEN plus wAnimationType 3
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle:performMove(battle.enemy, battle.player, { id = "FIX_POISON_STING", pp = 35 })
T.eq(battle.player.mon.status, "PSN", "the secondary poison landed")
local rows = animRows(battle)
local i, row = find(rows, "SHAKE_SCREEN_ANIM")
T.check(i ~= nil, "the enemy's turn queues SHAKE_SCREEN_ANIM")
T.eq(row.attackerIsPlayer, false, "attributed to the enemy side")
T.eq(row.hit and row.hit.animType, 3, "wAnimationType 3 rides the row")
T.eq(row.animDelayed, true, "PlayBattleAnimationGotID pays no Delay3")
local moveIdx = animIndex(battle, "FIX_POISON_STING")
local shakeIdx = animIndex(battle, "SHAKE_SCREEN_ANIM")
local textIdx = textIndex(battle, "poisoned")
T.check(moveIdx and shakeIdx and moveIdx < shakeIdx,
"the move animation still runs first")
T.check(textIdx and shakeIdx < textIdx,
"PlayBattleAnimation2 precedes PrintText (effects.asm:149-151)")
end
-- ---------------------------------------------------------------------
-- you poison the foe: SE_SHAKE_ENEMY_HUD plus wAnimationType 6
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle:performMove(battle.player, battle.enemy, { id = "FIX_POISON_STING", pp = 35 })
T.eq(battle.enemy.mon.status, "PSN", "the secondary poison landed")
local _, row = find(animRows(battle), "ENEMY_HUD_SHAKE_ANIM")
T.check(row ~= nil, "the player's turn queues ENEMY_HUD_SHAKE_ANIM")
T.eq(row.attackerIsPlayer, true, "attributed to the player side")
T.eq(row.hit and row.hit.animType, 6, "wAnimationType 6 rides the row")
T.check(find(animRows(battle), "SHAKE_SCREEN_ANIM") == nil,
"and never the enemy-side id")
end
-- ---------------------------------------------------------------------
-- burn takes the FreezeBurnParalyzeEffect arms: HUD shake on the player's
-- turn only, and no wAnimationType at all
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle:performMove(battle.player, battle.enemy, { id = "FIX_EMBERISH", pp = 25 })
T.eq(battle.enemy.mon.status, "BRN", "the secondary burn landed")
local _, row = find(animRows(battle), "ENEMY_HUD_SHAKE_ANIM")
T.check(row ~= nil, "the player's burn shakes the enemy HUD")
T.eq(row.hit, nil, "FreezeBurnParalyzeEffect zeroes wAnimationType")
local other = newBattle()
other.queue, other.nextInsert = {}, 0
other:performMove(other.enemy, other.player, { id = "FIX_EMBERISH", pp = 25 })
T.eq(other.player.mon.status, "BRN", "the enemy's burn landed too")
T.check(find(animRows(other), "ENEMY_HUD_SHAKE_ANIM") == nil,
"the enemy's-turn arm plays nothing")
end
-- ---------------------------------------------------------------------
-- a plain damaging move queues neither
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle:performMove(battle.player, battle.enemy, { id = "FIX_TACKLE", pp = 35 })
local rows = animRows(battle)
T.check(find(rows, "ENEMY_HUD_SHAKE_ANIM") == nil
and find(rows, "SHAKE_SCREEN_ANIM") == nil,
"no status, no PlayBattleAnimation2 row")
end
-- ---------------------------------------------------------------------
-- the residual tick plays BURN_PSN_ANIM with NO shake: core.asm:490-491
-- explicitly zeroes wAnimationType before it
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle.player.mon.status = "PSN"
battle:residualFor(battle.player, battle.enemy)
local _, row = find(animRows(battle), "BURN_PSN_ANIM")
T.check(row ~= nil, "the poison tick animates")
T.eq(row.hit, nil, "with no applying-attack shake")
T.eq(row.attackerIsPlayer, true, "on the hurt mon's side")
end
T.finish("secondary status animation (#1526)")
@@ -1,94 +0,0 @@
-- JoypadOverworld calls RunMapScript every overworld frame before input is
-- even read (home/overworld.asm:1816-1821), and the gate guards are
-- per-frame "is the player standing on these coords" checks
-- (scripts/Route16Gate1F.asm:16, Route5Gate.asm:19, Route22Gate.asm:21).
-- The port only evaluated them on a completed step, so saving on a guard's
-- tile and reloading walked past the guard (#1547).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
Data.tilesets.FIX_OUT.tilesPerRow = 16
Data.field.flyWarps = Data.field.flyWarps or {}
Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" }
Data.field.waterTilesets = {}
Data.field.forcedMovement = { tiles = {} }
Data.audio = Data.audio or {}
Data.audio.songs = Data.audio.songs or {}
Data.audio.mapSongs = Data.audio.mapSongs or {}
local SaveData = require("src.core.SaveData")
local Game = require("src.core.Game")
local StateStack = require("src.core.StateStack")
local OverworldState = require("src.world.OverworldController")
local MapScripts = require("src.script.MapScripts")
Game.data = Data
Game.save = SaveData.newGame()
Game.save.player.name = "RED"
Game.save.player.map = "FIX_TOWN"
StateStack:init()
Game.stack = StateStack
Game.overworld = OverworldState
Game.input = {
isDown = function() return false end,
wasPressed = function() return false end,
step = function() end, state = {}, pressQueue = {},
}
Game.renderer = {
beginWorldPass = function() end, endWorldPass = function() end,
beginUIPass = function() end, endUIPass = function() end,
worldViewSize = function() return 160, 144 end,
setSGBZones = function() end,
}
local TRIGGER_X, TRIGGER_Y = 4, 4
local fired = {}
MapScripts.attachBase("FIX_TOWN", {
onStep = function(_, _, x, y)
if x == TRIGGER_X and y == TRIGGER_Y then
fired[#fired + 1] = { x, y }
return true
end
return false
end,
})
local function loadedSave()
local save = SaveData.newGame()
save.player.map = "FIX_TOWN"
save.player.x, save.player.y = TRIGGER_X, TRIGGER_Y
return save
end
-- === the exploit: F2 / CONTINUE onto the guard's tile re-fires the guard
fired = {}
Game:restoreSave(loadedSave(), false, { freshBoot = true })
T.eq(#fired, 1, "a freshBoot restore re-evaluates the standing-tile trigger")
T.same(fired[1], { TRIGGER_X, TRIGGER_Y },
"at the coords the save left the player on")
-- === an ordinary warp arrival must NOT fire it
fired = {}
OverworldState:setMap("FIX_TOWN", TRIGGER_X, TRIGGER_Y, "up", {})
T.eq(#fired, 0, "a plain warp arrival still leaves the trigger to onStepComplete")
-- === dev tooling reuses opts.via == "boot" WITHOUT freshBoot
fired = {}
OverworldState:setMap("FIX_TOWN", TRIGGER_X, TRIGGER_Y, "up", { via = "boot" })
T.eq(#fired, 0, "the console warp / hot reload shape does not fire it")
-- === checkpoint resume must never re-run map scripts
fired = {}
Game:restoreCheckpointSave(loadedSave())
T.eq(#fired, 0, "a checkpoint resume re-runs nothing")
-- === restoring somewhere harmless fires nothing
fired = {}
local elsewhere = loadedSave()
elsewhere.player.x, elsewhere.player.y = 3, 3
Game:restoreSave(elsewhere, false, { freshBoot = true })
T.eq(#fired, 0, "a restore off the trigger cell is untouched")
T.finish("standing-tile triggers on restore (#1547)")
-87
View File
@@ -1,87 +0,0 @@
-- The Nugget Bridge recruiter has no def_trainers header, so the port's
-- headerless engageTrainer fallback re-printed his contest line as the
-- pre-battle box (#1550) and his loss line never reached the battle
-- screen (#1551). scripts/Route24.asm:120-134: .JoinTeamRocketText, then
-- SaveEndBattleTextPointers with .DefeatedText, then EngageMapTrainer with
-- no further box; Route24AfterRocketBattleScript (:62-78) prints
-- .YouCouldBecomeATopLeaderText on the map after the win.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
local SaveData = require("src.core.SaveData")
Data.items.NUGGET = Data.items.NUGGET
or { id = "NUGGET", index = 49, name = "NUGGET", price = 10000 }
Data.text._Route24CooltrainerM1YouBeatOurContestText =
"Congratulations!\nYou beat our 5\ncontest trainers!"
Data.text._Route24CooltrainerM1YouJustEarnedAPrizeText = "You just earned\na prize!"
Data.text._Route24CooltrainerM1ReceivedNuggetText = "{PLAYER} got\n{RAM:wStringBuffer}!"
Data.text._Route24CooltrainerM1JoinTeamRocketText = "Want to join us?"
Data.text._Route24CooltrainerM1DefeatedText = "Arrgh!\nYou are good!"
Data.text._Route24CooltrainerM1YouCouldBecomeATopLeaderText =
"With your ability,\nyou could become\na top leader!"
local pushed = {}
package.loaded["src.render.TextBox"] = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
substitute = function(_, s) return s end,
soundOpts = function(_, sound, opts)
opts = opts or {}
opts.auto = { sound = sound, wait = true, delay = 0 }
return opts
end,
}
local scripts = dofile("data/scripts/story4.lua")
local handler = scripts.ROUTE_24.talk.TEXT_ROUTE24_COOLTRAINER_M1
T.check(type(handler) == "function", "the recruiter has a hand-ported handler")
local game = {
data = Data,
save = SaveData.newGame(),
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}
local defeated = false
local engaged
local ow = {
trainerDefeated = function() return defeated end,
engageTrainer = function(_, npc, onDone, endBattleText, skipBattleText)
engaged = { npc = npc, onDone = onDone,
endBattleText = endBattleText, skipBattleText = skipBattleText }
end,
}
local npc = { id = "ROUTE24_ROCKET", def = { index = 1 } }
-- the prize has already been taken: the talk goes straight to the battle
game.save.flags.EVENT_GOT_NUGGET = true
local doneCalls = 0
handler(game, ow, npc, function() doneCalls = doneCalls + 1 end)
T.check(engaged ~= nil, "the recruiter engages")
T.eq(#pushed, 0, "no text box is pushed before the battle (#1550)")
T.eq(engaged.skipBattleText, true,
"skipBattleText stops the map text becoming the pre-battle box")
T.eq(engaged.endBattleText, Data.text._Route24CooltrainerM1DefeatedText,
"the loss line rides the battle, as SaveEndBattleTextPointers does (#1551)")
-- the win: Route24AfterRocketBattleScript prints the top-leader line
defeated = true
engaged.onDone()
T.eq(#pushed, 1, "the win prints exactly one box")
T.eq(pushed[1].text, Data.text._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
"and it is .YouCouldBecomeATopLeaderText")
pushed[1].onDone()
T.eq(doneCalls, 1, "control returns once the box closes")
-- the blackout arm: wIsInBattle == $ff rets before the DisplayTextID
pushed, defeated, doneCalls = {}, false, 0
handler(game, ow, npc, function() doneCalls = doneCalls + 1 end)
engaged.onDone()
T.eq(#pushed, 0, "a loss prints nothing")
T.eq(doneCalls, 1, "and just unfreezes the player")
T.finish("Nugget Bridge Rocket battle text (#1550, #1551)")
+5 -15
View File
@@ -41,8 +41,7 @@ _G.love = {
getVersion = function() return 12, 0, 0, "Mysterious Mysteries" end,
system = {
getOS = function() return "iOS" end,
getDeviceModel = function() return "iPhone16,2" end,
getModel = function() return "Apple A17 Pro GPU" end,
getModel = function() return "iPad Test" end,
openURL = function(url) openedURL = url end,
},
graphics = {
@@ -79,13 +78,10 @@ check(not url:find("game=", 1, true)
check(fields.summary == "" and fields.location == "" and fields.screenshot == ""
and fields.steps == "" and fields.expected == "",
"report leaves user-entered fields blank")
check(info.device == "iPhone 15 Pro Max"
and info.metadata:find("Device: iPhone 15 Pro Max", 1, true) ~= nil
check(info.metadata:find("Device: iPad Test", 1, true) ~= nil
and info.metadata:find("LÖVE: 12.0.0", 1, true) ~= nil
and info.metadata:find("Safe mode: on", 1, true) ~= nil,
"report metadata includes device and app details")
check(not info.metadata:find("Simulator GPU", 1, true),
"report metadata does not mistake the renderer device for the device")
check(not info.metadata:find("unknown", 1, true),
"report metadata omits unknown values")
check(not info.metadata:find("Game id", 1, true)
@@ -108,31 +104,25 @@ check(not developmentInfo.metadata:find("0.0.0-dev", 1, true),
local previousOS = love.system.getOS
local previousModel = love.system.getModel
local previousDeviceModel = love.system.getDeviceModel
local previousIO = _G.io
love.system.getOS = function() return "OS X" end
love.system.getModel = nil
love.system.getDeviceModel = nil
_G.io = {
popen = function(command)
local output = command:find("system_profiler", 1, true)
and "Hardware Overview:\n Model Name: MacBook Air\n Model Identifier: Mac14,15\n Chip: Apple M2\n"
or "Mac14,15\n"
popen = function()
return {
read = function() return output end,
read = function() return "MacBookPro18,3" end,
close = function() end,
}
end,
}
local desktopInfo = IssueReport.metadata({}, { mods = {} })
check(desktopInfo.device == "MacBook Air (Apple M2)",
check(desktopInfo.device == "MacBookPro18,3",
"report finds desktop device model when LOVE has no model")
love.system.getOS = function() return "UWP" end
local xboxInfo = IssueReport.metadata({}, { mods = {} })
check(xboxInfo.os == "Xbox", "report maps the Xbox runtime platform")
love.system.getOS = previousOS
love.system.getModel = previousModel
love.system.getDeviceModel = previousDeviceModel
_G.io = previousIO
local opened = IssueReport.open({ safeMode = false }, {
-76
View File
@@ -364,31 +364,6 @@ eq(bx, 0, "delta page box x") eq(by, 0, "delta page box y")
eq(bw, 1000, "delta page box fills the width")
eq(bh, 500, "delta page box fills the height")
local DECK_JSON = [[
{ "name": "Deck", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc",
"representations": { "iphone": { "standard": { "portrait": {
"assets": { "large": "deck.png" },
"mappingSize": {"width":320,"height":240},
"items": [ { "inputs": ["a"], "frame": {"x":240,"y":60,"width":64,"height":64} } ]
} } } } }
]]
local deck = assert(DeltaSkin.parse(DECK_JSON))
local deckPage = deck.pages[1]
check(deckPage.aspectFromCfg, "a portrait deck without screens keeps mapping aspect")
eq(deckPage.anchor, "bottom", "and sits at the bottom of the window")
eq(deckPage.screenFit, "remainder", "with the leftover given to the GB picture")
eq(deckPage.viewport, nil, "no screens[] means no baked cutout")
local dbx, dby, dbw, dbh = TouchSkin.pageBox(deckPage, 1080, 1920)
eq(dbx, 0, "deck overlay is full width")
eq(dbw, 1080, "deck overlay width")
near(dbh, 1080 * 240 / 320, "deck overlay height is mapping aspect")
near(dby, 1920 - dbh, "pinned to the bottom, not stretched")
local vx, vy, vw, vh = TouchSkin.pageViewport(deckPage, 1080, 1920)
eq(vx, 0, "screen leftover x") eq(vy, 0, "screen leftover y")
eq(vw, 1080, "screen leftover is full width")
near(vh, dby, "and fills everything above the overlay")
check(vh > dbh, "there is more room for the picture than for the pad")
local LEGACY_SCREEN = [[
{ "gameTypeIdentifier": "public.aoshuang.game.gbc",
"representations": { "iphone": { "standard": { "landscape": {
@@ -444,8 +419,6 @@ local PDF_JSON = [[
]]
local pdf = assert(DeltaSkin.parse(PDF_JSON))
eq(pdf.pages[1].imagePath, nil, "a PDF asset is not pretended to be art")
eq(pdf.pages[1].pdfPath, "iphone_portrait.pdf",
"but the PDF path is kept so load can extract a JPEG from it")
local convert = DeltaSkin.needsConversion(pdf)
check(convert ~= nil, "PDF-only skins report that they need conversion")
if convert then
@@ -498,55 +471,6 @@ check(tostring(vectorErr):find("PDF artwork", 1, true) ~= nil,
eq(love.filesystem.read("skins/vector.deltaskin"), nil,
"and the refused archive is not left behind")
local PdfImage = require("src.core.PdfImage")
local function unhex(s)
return (s:gsub("..", function(cc)
return string.char(tonumber(cc, 16))
end))
end
-- 1x1 JFIF JPEG, so extract tests do not need a file on disk.
local TINY_JPEG = unhex(
"ffd8ffe000104a46494600010100000100010000ffdb0043000806060706050807070709" ..
"09080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c283729" ..
"2c30313434341f27393d38323c2e333432ffc0000b080001000101011100ffc400140001" ..
"0000000000000000000000000000000008ffc40014100100000000000000000000000000" ..
"00000000ffda0008010100003f007f3fffd9")
local function jpegPdf(jpeg, w, h)
return "%PDF-1.7\n3 0 obj\n<< /Type /XObject /Subtype /Image /Width "
.. tostring(w) .. " /Height " .. tostring(h)
.. " /BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter /DCTDecode /Length "
.. tostring(#jpeg) .. " >>\nstream\n" .. jpeg .. "\nendstream\nendobj\n%%EOF\n"
end
local extracted = assert(PdfImage.extract(jpegPdf(TINY_JPEG, 1, 1)))
eq(extracted.ext, "jpg", "a JPEG-in-PDF yields a jpg")
eq(extracted.data, TINY_JPEG, "and the JPEG body is recovered byte for byte")
eq(extracted.width, 1, "width comes from the Image XObject")
eq(extracted.height, 1, "and so does height")
local indirect = "%PDF-1.7\n3 0 obj\n<< /Type /XObject /Subtype /Image /Width 1"
.. " /Height 1 /Filter /DCTDecode /Length 5 0 R >>\nstream\n" .. TINY_JPEG
.. "\nendstream\nendobj\n5 0 obj\n" .. tostring(#TINY_JPEG) .. "\nendobj\n%%EOF\n"
local fromRef = assert(PdfImage.extract(indirect))
eq(fromRef.data, TINY_JPEG,
"an indirect /Length (the 3-Heights Image-to-PDF layout) still extracts")
eq(select(1, PdfImage.extract("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")),
nil, "a vector PDF with no image is not pretended to be art")
eq(select(1, PdfImage.extract("not a pdf")), nil, "and neither is garbage")
love.filesystem.write("skins/pikapdf.deltaskin/info.json", PDF_JSON)
love.filesystem.write("skins/pikapdf.deltaskin/iphone_portrait.pdf",
jpegPdf(TINY_JPEG, 1, 1))
local pikaId, pikaErr = TouchSkin.installArchive("pikapdf.deltaskin", "PK\3\4stub")
eq(pikaId, "pikapdf", "a Delta skin whose PDF wraps a JPEG installs: "
.. tostring(pikaErr))
local pika = assert(TouchSkin.load("skins/_mounted/pikapdf", "pikapdf"))
check(pika.pages[1].rasterData == TINY_JPEG,
"load recovers the JPEG from the PDF")
check(pika.pages[1].image ~= nil, "and LOVE gets an image from those bytes")
eq(DeltaSkin.needsConversion(pika), nil,
"so the skin no longer reports that it needs conversion")
eq(select(1, TouchSkin.installArchive("skin.gbcskin", "PK\3\4stub")), nil,
"a GBA4iOS .gbcskin is refused at the door")
local _, legacyErr = TouchSkin.installArchive("skin.gbaskin", "PK\3\4stub")
@@ -1,122 +0,0 @@
-- SwitchPlayerMon (engine/battle/core.asm:2419-2423) prints RetreatMon and
-- holds 50 frames BEFORE the outgoing pic is recalled, and only then does
-- SendOutMon shout "Go! X!" (#1534). The port queued the send-out line
-- alone, so the withdraw box never existed. PlayerMon2Text's adjective
-- (engine/battle/common_text.asm:167-243) reads the ENEMY HP lost since
-- this mon switched in, from wLastSwitchInEnemyMonHP.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local Timing = require("src.core.Timing")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local function newBattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 30), Pokemon.new(Data, "FIXMON_B", 30) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 30)
battle.rng = function() return 0 end
battle.enemyAction = function() return { id = "FIX_SCRATCH", pp = 35 } end
return battle
end
-- drain the queue the way updateQueue does, recording rows in order plus
-- who was in the player slot when each row was emitted
local function drain(battle)
local rows = {}
for _ = 1, 400 do
local item = table.remove(battle.queue, 1)
if not item then return rows end
rows[#rows + 1] = { text = item.text, anim = item.anim,
auto = item.auto, autoDelay = item.autoDelay,
playerSpecies = battle.player.mon.species }
if item.fn then
battle.nextInsert = 0
item.fn()
end
end
error("the queue never drained")
end
local function indexOf(rows, pred)
for i, row in ipairs(rows) do
if pred(row) then return i end
end
return nil
end
-- ---------------------------------------------------------------------
-- the voluntary party-menu switch: withdraw line, then the send-out
-- ---------------------------------------------------------------------
do
local battle = newBattle()
drain(battle) -- the intro stamps lastSwitchInEnemyHP through sendOutText
local outgoing = battle.player.mon.species
local oldNick = battle.player.name
battle.queue, battle.nextInsert = {}, 0
battle:resolveSwitch(battle.game.save.party[2])
local rows = drain(battle)
local wIdx = indexOf(rows, function(r)
return r.text and r.text:find("Come back!", 1, true) ~= nil
end)
T.check(wIdx ~= nil, "the withdraw line is queued")
T.eq(rows[wIdx].text, oldNick .. " enough!\nCome back!",
"an untouched foe gives the `enough!` variant")
T.eq(rows[wIdx].auto, true, "the page ends `done`, so it never waits on A")
T.eq(rows[wIdx].autoDelay, Timing.SWITCH_PLAYER_MON,
"it holds the 50 frames DelayFrames pays (core.asm:2421-2422)")
T.eq(rows[wIdx].playerSpecies, outgoing,
"the outgoing mon is still in the slot while the line prints")
local sIdx = indexOf(rows, function(r)
return r.text and r.text:find("! ", 1, true) and r.text:find("Come back!", 1, true) == nil
end)
T.check(sIdx ~= nil and sIdx > wIdx, "the send-out shout follows the withdraw line")
T.eq(rows[sIdx].auto, true, "the send-out page ends `done` too (#1472)")
T.neq(rows[sIdx].playerSpecies, outgoing, "the swap happened between the two")
end
-- ---------------------------------------------------------------------
-- the adjective branches on enemy HP lost since the switch-in
-- ---------------------------------------------------------------------
do
local battle = newBattle()
drain(battle)
local nick = battle.player.name
local max = battle.enemy.mon.stats.hp
local quarter = math.floor(max / 4)
local function withdrawAt(dropPercent)
battle.lastSwitchInEnemyHP = max
battle.enemy.mon.hp = max - math.floor(dropPercent * quarter / 25)
return battle:withdrawText(nick)
end
T.eq(withdrawAt(0), nick .. " enough!\nCome back!", "no damage -> `enough!`")
T.eq(withdrawAt(50), nick .. " OK!\nCome back!", "30-69 -> `OK!`")
T.eq(withdrawAt(80), nick .. " good!\nCome back!", "70+ -> `good!`")
T.eq(withdrawAt(10), nick .. " \nCome back!", "1-29 -> no adjective at all")
end
-- ---------------------------------------------------------------------
-- ChooseNextMon (core.asm:1086-1128) calls SendOutMon with NO RetreatMon
-- ---------------------------------------------------------------------
do
local battle = newBattle()
drain(battle)
battle.queue, battle.nextInsert = {}, 0
battle.player.mon.hp = 0
battle:openReplacementMenu()
local rows = drain(battle)
T.check(indexOf(rows, function(r)
return r.text and r.text:find("Come back!", 1, true) ~= nil
end) == nil, "the post-faint replacement prints no withdraw line")
end
T.finish("switch withdraw text (#1534)")
-109
View File
@@ -1,109 +0,0 @@
-- THRASH/PETAL DANCE is a SpecialEffectsCont entry
-- (data/battle/special_effects.asm:22), so on the SETUP turn only,
-- engine/battle/core.asm:3129-3133 runs ThrashPetalDanceEffect before
-- damage; it ends in PlayBattleAnimation2 with SHRINKING_SQUARE_ANIM
-- (ANIM_B1 on the enemy's turn) plus the slow horizontal screen shake
-- (engine/battle/effects.asm:791-808, :1461-1471). The port queued only
-- the move's own animation (#1532).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
Data.moves.FIX_THRASH = {
id = "FIX_THRASH", index = 91, name = "FIX THRASH", type = "NORMAL",
power = 90, accuracy = 100, pp = 20, effect = "THRASH_PETAL_DANCE_EFFECT",
}
local function newBattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 40) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 40)
battle.rng = function(a) if a then return a end return 0 end
return battle
end
local function animRows(battle)
local rows = {}
for _, item in ipairs(battle.queue) do
if item.anim then
rows[#rows + 1] = { anim = item.anim, hit = item.hit,
attackerIsPlayer = item.attackerIsPlayer }
end
end
return rows
end
local function indexOf(rows, name)
for i, row in ipairs(rows) do
if row.anim == name then return i end
end
return nil
end
-- ---------------------------------------------------------------------
-- the player's setup turn: the effect animation precedes the move's own
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
local slot = { id = "FIX_THRASH", pp = 20 }
battle:performMove(battle.player, battle.enemy, slot)
local rows = animRows(battle)
local setup = indexOf(rows, "SHRINKING_SQUARE_ANIM")
local move = indexOf(rows, "FIX_THRASH")
T.check(setup ~= nil, "the setup turn queues SHRINKING_SQUARE_ANIM")
T.check(move ~= nil and setup < move,
"it plays BEFORE PlayPlayerMoveAnimation, as SpecialEffectsCont runs first")
T.eq(rows[setup].attackerIsPlayer, true, "on the player's side")
T.eq(rows[setup].hit and rows[setup].hit.animType, 6,
"wAnimationType 6 -> ShakeScreenHorizontallySlow2 on the player's turn")
-- the continuation turn never reaches the effect (.ThrashingAboutCheck,
-- core.asm:3532-3550 jumps straight to PlayerCalcMoveDamage)
battle.queue, battle.nextInsert = {}, 0
battle:performMove(battle.player, battle.enemy, slot)
T.check(indexOf(animRows(battle), "SHRINKING_SQUARE_ANIM") == nil,
"a locked-in Thrash queues no setup animation")
end
-- ---------------------------------------------------------------------
-- the enemy's turn takes ANIM_B1 and wAnimationType 3
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle:performMove(battle.enemy, battle.player, { id = "FIX_THRASH", pp = 20 })
local rows = animRows(battle)
local setup = indexOf(rows, "ANIM_B1")
T.check(setup ~= nil, "the enemy's setup turn queues ANIM_B1")
T.eq(rows[setup].attackerIsPlayer, false, "on the enemy's side")
T.eq(rows[setup].hit and rows[setup].hit.animType, 3,
"wAnimationType 3 -> ShakeScreenHorizontallySlow on the enemy's turn")
T.check(indexOf(rows, "SHRINKING_SQUARE_ANIM") == nil,
"and never the player-side id")
end
-- ---------------------------------------------------------------------
-- a missed setup turn still runs the effect (it precedes MoveHitTest)
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle.accuracyRoll = function() return false end
battle:performMove(battle.player, battle.enemy, { id = "FIX_THRASH", pp = 20 })
local rows = animRows(battle)
T.check(indexOf(rows, "SHRINKING_SQUARE_ANIM") ~= nil,
"the setup animation survives a miss")
T.check(indexOf(rows, "FIX_THRASH") == nil, "while the move's own anim is cancelled")
end
T.finish("thrash setup animation (#1532)")
@@ -1,120 +0,0 @@
-- _TrainerSentOutText (data/text/text_2.asm:923) and the
-- Go!/Do it!/Get'm! chain ending in _PlayerMon1Text (:1274-1294) end in
-- `done`, not `prompt`: PrintText returns and the flow runs straight into
-- AnimateSendingOutMon + PlayCry (engine/battle/core.asm:1421-1434,
-- :1723-1765). _AIBattleWithdrawText (:1-7) does end in `prompt` and
-- keeps its button wait. The port made every send-out box wait (#1472).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local function newBattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 30), Pokemon.new(Data, "FIXMON_B", 30) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 30)
battle.rng = function() return 0 end
battle.enemyAction = function() return { id = "FIX_SCRATCH", pp = 35 } end
return battle
end
local function rowWith(battle, needle)
for _, item in ipairs(battle.queue) do
if item.text and item.text:find(needle, 1, true) then return item end
end
return nil
end
-- ---------------------------------------------------------------------
-- the voluntary switch: the shout auto-continues into the send-out anim
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle:resolveSwitch(battle.game.save.party[2])
-- run the first act so the nested switch rows land in the queue
local first = table.remove(battle.queue, 1)
battle.nextInsert = 0
first.fn()
local withdraw = rowWith(battle, "Come back!")
T.check(withdraw ~= nil, "the withdraw line is queued")
T.eq(withdraw.auto, true, "RetreatMon's page ends `done` (#1534)")
local swap = table.remove(battle.queue, 2)
battle.nextInsert = 1
swap.fn()
local shout = rowWith(battle, battle.player.name)
T.check(shout ~= nil, "the send-out shout is queued")
T.eq(shout.auto, true, "_PlayerMon1Text ends `done`, so no button wait")
end
-- ---------------------------------------------------------------------
-- the AI switch: the sent-out box goes auto, the withdraw box does not,
-- and EnemySendOut's grow-in + cry now follow it
-- ---------------------------------------------------------------------
do
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
battle.rng = function() return 0 end
battle.enemyParty = { Pokemon.new(Data, "FIXMON_B", 30),
Pokemon.new(Data, "FIXMON_C", 30) }
battle.enemyIndex = 1
battle.enemy = battle.enemy or nil
battle.queue, battle.nextInsert = {}, 0
battle:executeAction(battle.enemy, battle.player,
{ special = "aiSwitch", index = 2 })
local withdrew = rowWith(battle, "with-")
local sent = rowWith(battle, "sent")
T.check(withdrew ~= nil, "the AI withdraw line is queued")
T.eq(withdrew.auto, nil, "_AIBattleWithdrawText ends `prompt` and still waits")
T.check(sent ~= nil, "the sent-out line is queued")
T.eq(sent.auto, true, "_TrainerSentOutText ends `done`")
T.eq(battle.enemySendingOut, true,
"the new pic stays hidden until AnimateSendingOutMon")
local acts = 0
for _, item in ipairs(battle.queue) do
if item.fn then acts = acts + 1 end
end
T.check(acts >= 1, "EnemySendOut queues the grow-in act after the text")
end
-- ---------------------------------------------------------------------
-- the post-faint replacement (ChooseNextMon -> SendOutMon, core.asm:1124)
-- ---------------------------------------------------------------------
do
local battle = newBattle()
local pushedUI
battle.game.stack.push = function(_, s) pushedUI = s end
battle.queue, battle.nextInsert = {}, 0
battle.player.mon.hp = 0
battle:openReplacementMenu()
local onSwitch
for _, item in ipairs(battle.queue) do
if item.ui then
local screen = item.ui()
onSwitch = screen and screen.onSwitch
end
end
onSwitch = onSwitch or (pushedUI and pushedUI.onSwitch)
if onSwitch then
battle.nextInsert = 0
onSwitch(battle.game.save.party[2])
local shout = rowWith(battle, battle.player.name)
T.check(shout ~= nil, "the replacement shout is queued")
T.eq(shout.auto, true, "SendOutMon's message ends `done` here too")
else
T.check(false, "the replacement menu offers an onSwitch callback")
end
end
T.finish("trainer send-out boxes (#1472)")
+3 -11
View File
@@ -96,8 +96,6 @@ local DATA = {
CYNDAQUIL = {
id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155, index = 155,
growthRate = "GROWTH_MEDIUM_SLOW",
-- data/pokemon/base_stats/cyndaquil.asm:10 GENDER_F12_5
genderRatio = 31,
types = { "FIRE", "FIRE" },
baseStats = {
hp = 39, attack = 52, defense = 43, speed = 65,
@@ -107,8 +105,6 @@ local DATA = {
TOTODILE = {
id = "TOTODILE", name = "TOTODILE", dex = 158, index = 158,
growthRate = "GROWTH_MEDIUM_SLOW",
-- data/pokemon/base_stats/totodile.asm:10 GENDER_F12_5
genderRatio = 31,
types = { "WATER", "WATER" },
baseStats = {
hp = 50, attack = 65, defense = 64, speed = 43,
@@ -167,11 +163,8 @@ end
-- is `levelMoves` and Gen 1 reads level1Moves / learnset.
local function mon(species, level, opts)
opts = opts or {}
-- engine/pokemon/mon_stats.asm:126 GetGender reads the Attack DV, so the
-- gender a page prints follows the DVs, not a hand-set field
local built = Mon.new(DATA, species, level, {
dvs = opts.dvs
or { attack = 15, defense = 15, speed = 15, special = 15 },
dvs = { attack = 15, defense = 15, speed = 15, special = 15 },
moves = opts.moves,
})
for key, value in pairs(opts.fields or {}) do built[key] = value end
@@ -184,13 +177,12 @@ local CYNDA = mon("CYNDAQUIL", 12, {
{ id = "EMBER", pp = 25, maxPp = 25 },
{ id = "LEER", pp = 7, maxPp = 30 },
},
fields = { nickname = "CYNDAQUIL", item = "BERRY",
fields = { nickname = "CYNDAQUIL", gender = "male", item = "BERRY",
otName = "GOLD", otId = 12345 },
})
local TOTO = mon("TOTODILE", 10, {
moves = { { id = "SURF", pp = 15, maxPp = 15 } },
dvs = { attack = 0, defense = 15, speed = 15, special = 15 },
fields = { nickname = "TOTODILE" },
fields = { nickname = "TOTODILE", gender = "female" },
})
local SAVE = { player = { name = "GOLD", id = 12345 }, party = { CYNDA, TOTO } }
-4
View File
@@ -226,10 +226,6 @@ return { spawns = { SPAWN_NEW_BARK = { map = "TEST_MAP", x = 1, y = 1 } } }
spawnAfterChampion = "SPAWN_LANCE",
position = { map = "PLAYERS_HOUSE_2F", x = 1, y = 1, facing = "down" } }),
"and the post-credits spawn is a warp even though a position exists")
love.filesystem.remove("data/generated/maps.lua")
love.filesystem.remove("data/generated/tilesets.lua")
love.filesystem.remove("data/generated/landmarks.lua")
end
-- ---- the two clock faces ----------------------------------------------------
+7 -20
View File
@@ -2876,9 +2876,8 @@ check(selGame.save.registeredItem == nil,
eq(selWorld:useSelectItem(), "not_registered",
"SELECT with nothing registered answers not_registered")
-- The PACK side: the item submenu's SEL row is RegisterItem's only door --
-- the cart's SELECT is the bag's own item shuffle
-- (engine/items/pack.asm:1290 Pack_InterpretJoypad .select).
-- The PACK side: SELECT on a highlighted row is RegisterItem, the one PACK
-- button this port left unbound.
selGame.save.inventory.POTION = 3
selGame.input = stubInput()
local selPack = PackMenu.new(selGame, { pocket = "ITEM" })
@@ -2886,18 +2885,7 @@ selPack.index = 1
check(selPack.rows[1].id == "POTION", "the ITEM pocket row under test")
selGame.input:press("select")
selPack:update(0)
eq(selPack.switching, 1, "SELECT on a row arms the item shuffle")
check(selGame.save.registeredItem == nil, "and registers nothing")
selGame.input:press("b")
selPack:update(0)
check(selPack.switching == nil and selPack.message == nil,
"B backs out of the shuffle")
selPack:openSubmenu()
check(table.concat(selPack.submenu.rows, ","):find("sel", 1, true) ~= nil,
"the POTION submenu offers the SEL row")
selPack:closeSubmenu()
selPack:registerSelected()
check(selPack.message ~= nil, "SEL opens RegisteredItemText")
check(selPack.message ~= nil, "SELECT on a row opens RegisteredItemText")
eq(selGame.save.registeredItem.id, "POTION",
"and World:registerItem actually ran")
selGame.input:press("a")
@@ -2909,9 +2897,7 @@ selPack.index = selPack:total()
selGame.save.registeredItem = nil
selGame.input:press("select")
selPack:update(0)
check(selPack.switching == nil and selPack.message == nil,
"SELECT on CANCEL arms nothing")
selPack:registerSelected()
check(selPack.message == nil, "SELECT on CANCEL registers nothing")
check(selGame.save.registeredItem == nil, "and the slot stays empty")
-- CantRegisterText: a TM/HM row refuses from the PACK too.
@@ -2920,8 +2906,9 @@ selPack:rebuild()
local tmPack = PackMenu.new(selGame, { pocket = "TM_HM" })
tmPack.index = 1
check(tmPack.rows[1].id == "HM_CUT", "the TM/HM pocket row under test")
tmPack:registerSelected()
check(tmPack.message ~= nil, "SEL on the HM still opens a message")
selGame.input:press("select")
tmPack:update(0)
check(tmPack.message ~= nil, "SELECT on the HM still opens a message")
check(selGame.save.registeredItem == nil,
"CantRegisterText: the HM never becomes the registered item")
end
+1 -2
View File
@@ -315,9 +315,8 @@ local vanillaSave = SaveData.newGame(Data.field.boot)
-- special_warps.asm NewGameWarp is REDS_HOUSE_2F, 3, 6 -- the bedroom. This
-- previously asserted PALLET_TOWN (5, 6), which is where you stand after
-- walking out of the house, so a new game skipped Red's house entirely.
-- Red/Blue land facing up (#944); only Yellow keeps boot.startFacing.
check(vanillaSave.player.map == "REDS_HOUSE_2F" and vanillaSave.player.x == 3
and vanillaSave.player.y == 6 and vanillaSave.player.facing == "up",
and vanillaSave.player.y == 6 and vanillaSave.player.facing == "down",
"the seeded boot config reproduces the NewGameWarp bedroom spawn")
check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE"
and vanillaSave.money == 3000, "the seeded boot config reproduces the Red start")
+36
View File
@@ -267,13 +267,49 @@ end
do
local BattleState = require("src.battle.BattleState")
local Gen2BattleState = require("src.ui.gen2.BattleState")
local battle = { wideLayout = function() return false end }
check(not BattleState.moveGridNavigation(battle),
"classic move navigation stays a list without a mod")
check(not Gen2BattleState.moveGridNavigation({}),
"Gold move navigation stays a list without a mod")
local unsub = wrap("battle.move_grid_navigation", function() return true end)
check(BattleState.moveGridNavigation(battle),
"a mod can opt the classic move menu into grid navigation")
check(Gen2BattleState.moveGridNavigation({}),
"the same hook opts Gold's move menu into grid navigation")
local pressed, moveCount = "right", 4
local gold = setmetatable({
phase = "moves", moveIndex = 1,
slideFrame = math.huge,
game = { input = {
wasPressed = function(_, key) return key == pressed end,
} },
updateAlarm = function() end,
stepHpAnim = function() return false end,
playerMoves = function()
local moves = {}
for i = 1, moveCount do moves[i] = {} end
return moves
end,
}, { __index = Gen2BattleState })
gold:update(0)
check(gold.moveIndex == 2,
"Gold grid navigation moves right across a companion move row")
pressed, gold.moveIndex = "down", 1
gold:update(0)
check(gold.moveIndex == 3,
"Gold grid navigation moves down the companion move column")
pressed, moveCount, gold.moveIndex = "down", 3, 2
gold:update(0)
check(gold.moveIndex == 2,
"Gold grid navigation does not select an empty fourth move slot")
unsub()
pressed, gold.moveIndex = "right", 1
gold:update(0)
check(gold.moveIndex == 1,
"removing the hook restores Gold's native vertical move list")
battle.wideLayout = function() return true end
check(BattleState.moveGridNavigation(battle),
"the native wide move grid remains enabled without a mod")
-2
View File
@@ -276,8 +276,6 @@ do
text = text:gsub("{RIVAL}", save.player.rival or "BLUE")
if game.stringBuffer then
text = text:gsub("{RAM:wStringBuffer}", game.stringBuffer)
-- wNameBuffer reads the same buffer in this port (TextBox.TOKENS.RAM)
text = text:gsub("{RAM:wNameBuffer}", game.stringBuffer)
end
text = text:gsub("{[%w_:]+}", "")
return text
+30 -44
View File
@@ -282,19 +282,13 @@ local function optGame()
end
local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
"battleFit", "battleHud", "battleBg", "uiLayout",
"battleFit", "battleBg", "uiLayout",
"ruleset", "musicVol", "sfxVol", "musicFilter",
"performance", "colors",
"tilt", "gbcfx", "zoom", "voidFill", "videoMode",
"faithfulRes", "fpsCap",
"speedOverworld", "speedBattle", "speedMenu",
"mods", "controls", "dateFormat", "timeFormat" }
local function orow(menu, id)
for _, row in ipairs(menu.rows) do
if row.id == id then return row end
end
error("no options row '" .. id .. "'")
end
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
for i, id in ipairs(WANT_IDS) do
check(om.rows[i].id == id, "options row order: " .. id)
@@ -302,12 +296,11 @@ end
-- ruleset row cycles the sorted non-hidden registry ids showing name
om.game.save.options.ruleset = "gen1_faithful"
check(orow(om, "ruleset").value(om.game) == "GEN 1",
"ruleset row shows record.name")
orow(om, "ruleset").step(om.game, 1)
check(om.rows[8].value(om.game) == "GEN 1", "ruleset row shows record.name")
om.rows[8].step(om.game, 1)
check(om.game.save.options.ruleset == "modern_clean",
"ruleset row cycles sorted registry ids")
orow(om, "ruleset").step(om.game, 1)
om.rows[8].step(om.game, 1)
check(om.game.save.options.ruleset == "gen1_faithful",
"hidden rulesets are excluded from the cycle")
@@ -326,42 +319,42 @@ check(om.game.save.options.battleLayout == "wide", "battle layout flips to WIDE"
check(om.rows[4].value(om.game) == "WIDE", "the WIDE layout renders its label")
om.rows[4].step(om.game, 1)
check(om.game.save.options.battleLayout == "og", "battle layout flips back")
orow(om, "musicVol").step(om.game, -1)
om.rows[9].step(om.game, -1)
check(om.game.save.options.musicVol == 6, "music volume steps down")
for _ = 1, 10 do orow(om, "musicVol").step(om.game, -1) end
for _ = 1, 10 do om.rows[9].step(om.game, -1) end
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
-- ZOOM / VOID FILL rows (looked up by id; WANT_IDS above pins the order)
-- ZOOM / VOID FILL rows (indices track WANT_IDS above; the battle
-- composition rows -- BATTLE SIZE / BATTLE BG / UI LAYOUT -- sit ahead of
-- RULESET, and FAITHFUL RATIO lands between VIDEO MODE and MAX FPS)
local Zoom = require("src.render.Zoom")
local TileRenderer = require("src.render.TileRenderer")
om.game.save.options.zoom = 0
Zoom.offset = 0
check(orow(om, "zoom").value(om.game) == "FIT",
"ZOOM row shows FIT at offset 0")
orow(om, "zoom").step(om.game, 1)
check(om.rows[16].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
om.rows[16].step(om.game, 1)
check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
"ZOOM row steps to IN1")
orow(om, "voidFill").step(om.game, 1)
om.rows[17].step(om.game, 1)
check(om.game.save.options.voidFill == "water"
and TileRenderer.voidFill == "water",
"VOID FILL row cycles TREES → WATER")
orow(om, "voidFill").step(om.game, 1)
om.rows[17].step(om.game, 1)
check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK")
orow(om, "voidFill").step(om.game, 1)
om.rows[17].step(om.game, 1)
check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES")
-- the MAX FPS row cycles the render-cap steps and shows the value plain
om.game.save.options.fpsCap = nil
check(orow(om, "fpsCap").value(om.game) == "60",
check(om.rows[20].value(om.game) == "60",
"MAX FPS row defaults to 60 with no saved cap")
orow(om, "fpsCap").step(om.game, 1)
om.rows[20].step(om.game, 1)
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
check(orow(om, "fpsCap").value(om.game) == "75",
"the MAX FPS row renders the cap")
check(om.rows[20].value(om.game) == "75", "the MAX FPS row renders the cap")
om.game.save.options.fpsCap = 160
orow(om, "fpsCap").step(om.game, 1)
om.rows[20].step(om.game, 1)
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
orow(om, "fpsCap").step(om.game, -1)
om.rows[20].step(om.game, -1)
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
-- ------- FrameCap normalize / cycle (issue #88)
@@ -391,7 +384,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6
-- the MODS row is the manager's discoverable home
local mgGame = optGame()
om = OptionsMenu.new(mgGame)
orow(om, "mods").activate(mgGame)
om.rows[24].activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState",
@@ -401,7 +394,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame()
om = OptionsMenu.new(cbGame)
orow(om, "controls").activate(cbGame)
om.rows[25].activate(cbGame)
local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list")
@@ -420,17 +413,17 @@ check(cbGame.save.options.bindings == nil,
-- engine UI and mods without becoming checkpoint progress
om.game.save.options.dateFormat = "device"
om.game.save.options.timeFormat = "device"
check(orow(om, "dateFormat").value(om.game) == "DEVICE",
check(om.rows[26].value(om.game) == "DEVICE",
"DATE FORMAT defaults to device locale")
orow(om, "dateFormat").step(om.game, 1)
om.rows[26].step(om.game, 1)
check(om.game.save.options.dateFormat == "dmy"
and orow(om, "dateFormat").value(om.game) == "DD-MM-YYYY",
and om.rows[26].value(om.game) == "DD-MM-YYYY",
"DATE FORMAT exposes deterministic DMY override")
check(orow(om, "timeFormat").value(om.game) == "DEVICE",
check(om.rows[27].value(om.game) == "DEVICE",
"TIME FORMAT defaults to device locale")
orow(om, "timeFormat").step(om.game, 1)
om.rows[27].step(om.game, 1)
check(om.game.save.options.timeFormat == "24h"
and orow(om, "timeFormat").value(om.game) == "24 HOUR",
and om.rows[27].value(om.game) == "24 HOUR",
"TIME FORMAT exposes deterministic 24-hour override")
check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil,
"no raw-input claim until a capture is armed")
@@ -590,19 +583,11 @@ check(not fpm.submenu and forced == fgame.save.party[1],
-- ------- issues #320/#385: the STRENGTH texts print over the party menu
do
-- PartyMenu delegates the move to OverworldState:useStrengthFieldMove;
-- parity_I_M covers that side, this one covers what the menu does after
local owStub = { strengthActive = false,
map = { def = { tileset = "OVERWORLD" } }, dark = false,
partyKnows = function(self, id) return self.knows == id end,
knows = "STRENGTH" }
local sgame = partyGame()
owStub.useStrengthFieldMove = function(self, _mon, onClose)
self.strengthActive = true
sgame.stack:push(require("src.render.TextBox").new(
sgame, "used\nSTRENGTH.", onClose))
return true
end
sgame.overworld = owStub
sgame.data.text = {} -- the strength texts fall back to Strings sources
sgame.save.inventory.RAINBOWBADGE = 1
@@ -1248,7 +1233,7 @@ local Loader = require("src.mods.Loader")
local uiFiles = {
["mods/uikit/manifest.json"] =
'{"id":"uikit","name":"uikit","version":"1.0.0","entry":"main.lua","api":2}',
["mods/uikit/main.lua"] = "return function(mod) mod.exports.api = mod end",
["mods/uikit/main.lua"] = "return function(mod) _G.MOD_UI_API = mod end",
}
local uiFs = {
read = function(path) return uiFiles[path] end,
@@ -1282,7 +1267,8 @@ local uiFs = {
}
local uiLoader = Loader.new({ fs = uiFs })
check(uiLoader:load({}) == true, "the uikit fixture loads clean")
local uiApi = (uiLoader.exports.uikit or {}).api
local uiApi = _G.MOD_UI_API
_G.MOD_UI_API = nil
check(uiApi ~= nil, "the entry chunk received its api")
check(uiApi.ui == ModUI, "mod.ui is the toolkit facade")
check(uiApi.ui.Theme == Theme, "mod.ui.Theme reaches the theme module")
-1
View File
@@ -125,7 +125,6 @@ do
function tb:enemyAction() return { special = "bound" } end
tb:resolveSwitch(Game.save.party[2])
acts[1]() -- send-out clears foe trap
acts[#acts]()
eq(tb.enemy.trappingTurns, nil, "player switch clears foe Wrap/Bind/etc.")
eq(tb.enemy.trapMove, nil, "player switch clears trapMove")
check(tb:fightLockedAction(tb.player) == nil,
+2 -2
View File
@@ -222,7 +222,7 @@ eq(next(t), nil, "already-left Route 25 enter is a no-op")
Commands.hide_object = realHide
Commands.show_object = realShow
package.loaded["src.ui.Menu"] = realMenu
package.loaded["src.core.Music"] = realMusic
package.loaded["src.core.Sound"] = realSound
if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic end
if realSound ~= nil then package.loaded["src.core.Sound"] = realSound end
S.finish()
-11
View File
@@ -35,13 +35,6 @@ local WILD_SONG = Data.audio.battle.wild
-- every song request in order: "the theme was never restored" and "the theme
-- was never started" have to read differently. Music.playMap still sets the
-- state Music.restoreMap reads, so the real restore path is under test.
-- singletons this file stands doubles on; the originals go back at the tail,
-- or a later suite in the same process inherits them (a stubbed startWarpTo
-- eats every warp after this one)
local realPlay, realPlayBattle = Music.play, Music.playBattle
local realStartWarpTo = OW.startWarpTo
local realEvents = Runtime.events
local songs = {}
Music.play = function(_, song) songs[#songs + 1] = song end
local function lastSong() return songs[#songs] end
@@ -167,8 +160,4 @@ eq(labResult, "lose", "it still finishes as a loss")
eq(Game.save.money, 3000, "no money is lost in the lab")
eq(warp, nil, "and the player stays in the lab for OaksLabRivalEndBattleScript")
OW.startWarpTo = realStartWarpTo
Music.play, Music.playBattle = realPlay, realPlayBattle
Runtime.install(realEvents, Runtime.hooks)
S.finish()
+2 -3
View File
@@ -33,8 +33,7 @@ local function battleWith(partyHP, result)
for i, hp in ipairs(partyHP) do
party[i] = { species = "SQUIRTLE", hp = hp, stats = { hp = 20 } }
end
-- the metatable so playerMonFainted can reach playerPartyView
return setmetatable({
return {
kind = "wild",
result = result,
afterQueue = nil,
@@ -45,7 +44,7 @@ local function battleWith(partyHP, result)
sayNext = function(self, m) self.said[#self.said + 1] = m end,
say = function(self, m) self.said[#self.said + 1] = m end,
ui = function() end,
}, BattleState)
}
end
local function saidBlackout(b)
+41 -36
View File
@@ -2642,6 +2642,7 @@ do
local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap")
local SD = require("src.core.SaveData")
-- Isolate from earlier save/options writes in this suite
@@ -2662,19 +2663,6 @@ do
om:update(1 / 60)
OInput.pressed = {}
end
-- walk the cursor down to a row by id, so a row added to OptionsMenu
-- shifts these blocks instead of silently retargeting them
local function seek(id)
local want = -1
for i, row in ipairs(om.rows) do
if row.id == id then want = i end
end
for _ = 1, #om.rows do
if om.index == want then break end
press("down")
end
return om.index == want
end
eq(og.save.options.textSpeed, 3,
"new saves default to MEDIUM text (InitOptions TEXT_DELAY_MEDIUM)")
eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC")
@@ -2685,58 +2673,65 @@ do
eq(og.save.options.videoMode, "windowed",
"new saves default VIDEO MODE to WINDOWED")
eq(om.scroll, 0, "options viewport starts at the top")
check(seek("battleLayout"), "cursor reaches BATTLE LAYOUT")
for _ = 1, 3 do press("down") end
eq(om.index, 4, "cursor reaches BATTLE LAYOUT")
press("a")
eq(og.save.options.battleLayout, "wide",
"A switches the battle screen to the WIDE layout")
press("a")
eq(og.save.options.battleLayout, "og", "BATTLE LAYOUT wraps back to OG")
check(seek("musicVol"), "cursor reaches MUSIC VOL")
eq(om.scroll, om.index - require("src.ui.OptionRows").VISIBLE,
"viewport scrolls to keep MUSIC VOL on screen")
for _ = 1, 5 do press("down") end
eq(om.index, 9, "cursor reaches MUSIC VOL")
eq(om.scroll, 5, "viewport scrolls to keep MUSIC VOL on screen")
press("left")
eq(og.save.options.musicVol, 6, "left lowers MUSIC VOL")
press("right")
eq(og.save.options.musicVol, 7, "right raises MUSIC VOL back")
press("right")
eq(og.save.options.musicVol, 7, "MUSIC VOL clamps at 7")
seek("sfxVol"); press("left")
press("down"); press("left")
eq(og.save.options.sfxVol, 6, "SFX VOL adjusts on its own row")
seek("musicFilter")
press("down")
for _ = 1, 3 do press("a") end
eq(og.save.options.musicFilter, 3, "A cycles MUSIC FILTER to 3X")
press("a")
eq(og.save.options.musicFilter, 0, "MUSIC FILTER wraps back to OFF")
check(seek("performance"), "cursor reaches PERFORMANCE")
press("down")
eq(om.index, 12, "cursor reaches PERFORMANCE")
press("a")
eq(og.save.options.performance, "high", "A cycles PERFORMANCE to HIGH")
eq(require("src.core.Performance").tier, "high",
"the live tier tracks the PERFORMANCE option")
for _ = 1, 3 do press("a") end
eq(og.save.options.performance, "auto", "PERFORMANCE wraps back to AUTO")
check(seek("colors"), "cursor reaches COLORS")
press("down")
eq(om.index, 13, "cursor reaches COLORS")
press("a")
for _ = 1, 4 do press("a") end
check(seek("tilt"), "cursor reaches TILT")
press("down")
eq(om.index, 14, "cursor reaches TILT")
press("a")
eq(og.save.options.tilt, 1, "A cycles TILT to 15")
eq(Tilt.level, 1, "Tilt level tracks TILT option")
press("a"); press("a"); press("a")
eq(og.save.options.tilt, 0, "TILT wraps back to OFF")
check(seek("gbcfx"), "cursor reaches GBC FX")
press("down")
eq(om.index, 15, "cursor reaches GBC FX")
press("a")
eq(og.save.options.gbcfx, 1, "A cycles GBC FX to 1")
eq(GBCFX.level, 1, "GBCFX level tracks GBC FX option")
for _ = 1, 4 do press("a") end
eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF")
check(seek("zoom"), "cursor reaches ZOOM")
press("down")
eq(om.index, 16, "cursor reaches ZOOM")
local ZoomOpt = require("src.render.Zoom")
press("a")
eq(og.save.options.zoom, 1, "A cycles ZOOM to IN1")
eq(ZoomOpt.offset, 1, "Zoom.offset tracks ZOOM option")
press("left")
eq(og.save.options.zoom, 0, "left steps ZOOM back to FIT")
check(seek("voidFill"), "cursor reaches VOID FILL")
press("down")
eq(om.index, 17, "cursor reaches VOID FILL")
local TR = require("src.render.TileRenderer")
press("a")
eq(og.save.options.voidFill, "water", "A cycles VOID FILL to WATER")
@@ -2745,15 +2740,18 @@ do
eq(og.save.options.voidFill, "black", "A cycles VOID FILL to BLACK")
press("a")
eq(og.save.options.voidFill, "trees", "VOID FILL wraps back to TREES")
check(seek("videoMode"), "cursor reaches VIDEO MODE")
press("down")
eq(om.index, 18, "cursor reaches VIDEO MODE")
press("a")
eq(og.save.options.videoMode, "borderless",
"A cycles VIDEO MODE to BORDERLESS")
press("a")
eq(og.save.options.videoMode, "windowed",
"VIDEO MODE wraps back to WINDOWED")
check(seek("faithfulRes"), "cursor reaches FAITHFUL RATIO")
check(seek("fpsCap"), "cursor reaches MAX FPS")
press("down")
eq(om.index, 19, "cursor reaches FAITHFUL RATIO")
press("down")
eq(om.index, 20, "cursor reaches MAX FPS")
press("a")
eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75")
eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option")
@@ -2763,7 +2761,8 @@ do
eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60")
-- RFC 0007: the single GAME SPEED row is now three independent rows,
-- one per GameSpeed.CATEGORIES entry.
check(seek("speedOverworld"), "cursor reaches OVERWORLD SPEED")
press("down")
eq(om.index, 21, "cursor reaches OVERWORLD SPEED")
press("a")
eq(og.save.options.speedOverworld, 2, "A cycles OVERWORLD SPEED to 2X")
-- Driven by the level list rather than a literal press count: adding a
@@ -2771,20 +2770,26 @@ do
-- bug when the cycling is fine and the row is simply one longer.
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speedOverworld, 1, "OVERWORLD SPEED wraps back to NORMAL")
check(seek("speedBattle"), "cursor reaches BATTLE SPEED")
press("down")
eq(om.index, 22, "cursor reaches BATTLE SPEED")
press("a")
eq(og.save.options.speedBattle, 2, "A cycles BATTLE SPEED to 2X")
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speedBattle, 1, "BATTLE SPEED wraps back to NORMAL")
check(seek("speedMenu"), "cursor reaches MENU SPEED")
press("down")
eq(om.index, 23, "cursor reaches MENU SPEED")
press("a")
eq(og.save.options.speedMenu, 2, "A cycles MENU SPEED to 2X")
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speedMenu, 1, "MENU SPEED wraps back to NORMAL")
check(seek("mods"), "cursor reaches MODS")
check(seek("controls"), "cursor reaches CONTROLS")
check(seek("dateFormat"), "cursor reaches DATE FORMAT")
check(seek("timeFormat"), "cursor reaches TIME FORMAT")
press("down")
eq(om.index, 24, "cursor reaches MODS")
press("down")
eq(om.index, 25, "cursor reaches CONTROLS")
press("down")
eq(om.index, 26, "cursor reaches DATE FORMAT")
press("down")
eq(om.index, 27, "cursor reaches TIME FORMAT")
press("down")
-- CANCEL is appended after the descriptor list rather than living in it, so
-- it lands one past #rows and the window holds the last six boxes. Counted
@@ -2807,7 +2812,7 @@ do
GBCFX.applyOptions(og.save.options)
require("src.render.Zoom").applyOptions(og.save.options)
require("src.render.TileRenderer").applyOptions(og.save.options)
require("src.core.VideoMode").applyOptions(og.save.options)
VideoMode.applyOptions(og.save.options)
end
end