mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 20:20:19 +02:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e24f812475 | |||
| f8ba51636b | |||
| fb97318e87 | |||
| fc83ecd52f | |||
| 5ba49bdf3f | |||
| c01bda3570 | |||
| 74e04cb086 | |||
| 0ea224d5db | |||
| c2e1db0f89 | |||
| 4c13770e70 | |||
| cf45cbbf92 | |||
| 7d1ddf9b7c | |||
| faf82c2cec | |||
| e0e030003b | |||
| ce2afb83f1 | |||
| 28f741f72f | |||
| ea28f886f3 | |||
| 6cd8f0ddea | |||
| fb4eaeda10 | |||
| 69100301a1 | |||
| 114352b75f | |||
| 0e40a7a1f4 |
@@ -1 +0,0 @@
|
||||
* @bryanthaboi
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
[](https://youtu.be/yi7LkWQPKKM)
|
||||
[](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 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
a5522634f1e581a1ebab73bf3ab4bd7a853b7a3e
|
||||
@@ -1 +0,0 @@
|
||||
97d4465e8c81099f79696ea4b4bb8b1f9083bc1a
|
||||
@@ -1 +0,0 @@
|
||||
12.0
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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."
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)")
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)")
|
||||
@@ -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)")
|
||||
@@ -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 }, {
|
||||
|
||||
@@ -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)")
|
||||
@@ -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)")
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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 ----------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user