diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..ca79ca5b
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,6 @@
+version: 2
+updates:
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
diff --git a/.github/resources/altstore-badge.png b/.github/resources/altstore-badge.png
new file mode 100644
index 00000000..9b7568a0
Binary files /dev/null and b/.github/resources/altstore-badge.png differ
diff --git a/.github/resources/feather-badge.png b/.github/resources/feather-badge.png
new file mode 100644
index 00000000..2cacc649
Binary files /dev/null and b/.github/resources/feather-badge.png differ
diff --git a/.github/resources/github-badge.png b/.github/resources/github-badge.png
new file mode 100644
index 00000000..6583de99
Binary files /dev/null and b/.github/resources/github-badge.png differ
diff --git a/.github/resources/sidestore-badge.png b/.github/resources/sidestore-badge.png
new file mode 100644
index 00000000..ffc7121a
Binary files /dev/null and b/.github/resources/sidestore-badge.png differ
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 37e30ec4..afc99c23 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,8 +10,6 @@ name: ci
# The T3 content tier asserts Pokemon Red facts; scripts/test.sh detects
# data/generated/ is absent and skips it rather than failing.
#
-# Runs alongside release.yml, which is untouched by this file.
-
on:
push:
# Integration branch + release branch. PRs already run via pull_request
@@ -24,12 +22,91 @@ concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
+permissions:
+ contents: read
+
jobs:
+ ios-changes:
+ name: detect iOS 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 '^(mobile/ios/|scripts/build_ios\.sh$|\.github/workflows/(ci|release)\.yml$)'; then
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ ios-build:
+ name: iOS build
+ needs: ios-changes
+ if: needs.ios-changes.outputs.changed == 'true'
+ runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
+ outputs:
+ ipa_url: ${{ steps.upload-ipa.outputs.artifact-url }}
+ steps:
+ - uses: actions/checkout@v7
+ - name: import signing certificate
+ if: github.repository == 'bryanthaboi/gen1recomp'
+ run: |
+ keychain_path="$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db"
+ ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}"
+ p12="$ci_dir/signing.p12"
+ passfile="$ci_dir/signing.pass"
+ [ -f "$p12" ] && [ -f "$passfile" ] || exit 1
+ p12pw="$(cat "$passfile")"
+ kcpw="$(openssl rand -base64 24)"
+ echo "::add-mask::$kcpw"
+ security delete-keychain "$keychain_path" 2>/dev/null || true
+ security create-keychain -p "$kcpw" "$keychain_path"
+ security set-keychain-settings "$keychain_path"
+ security unlock-keychain -p "$kcpw" "$keychain_path"
+ security import "$p12" -P "$p12pw" -k "$keychain_path" -T /usr/bin/codesign -T /usr/bin/security
+ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$kcpw" "$keychain_path" >/dev/null
+ existing="$(security list-keychains -d user | sed -e 's/^[[:space:]]*//' -e 's/"//g')"
+ security list-keychains -d user -s "$keychain_path" $existing
+ - name: install xcbeautify
+ run: brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
+ - name: build iOS release
+ env:
+ CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
+ run: |
+ if [ "$CANONICAL_REPOSITORY" = true ]; then
+ scripts/build_ios.sh --fetch --device --release
+ else
+ scripts/build_ios.sh --fetch --release
+ fi
+ - name: upload iOS release artifact
+ id: upload-ipa
+ if: github.repository == 'bryanthaboi/gen1recomp'
+ uses: actions/upload-artifact@v7
+ with:
+ name: gen1recomp-ios-ipa
+ path: dist/ios/gen1recomp.ipa
+ if-no-files-found: error
+ retention-days: 7
+ - name: clean up signing keychain
+ if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
+ run: security delete-keychain "$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db" 2>/dev/null || true
+
headless:
name: headless suites (no ROM)
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
# LuaJIT, not lua5.4: LOVE 11.x embeds LuaJIT 2.1 and the engine is
# written to Lua 5.1 semantics, so CI must run the interpreter the
@@ -47,7 +124,7 @@ jobs:
name: fixture dataset integrity
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- run: sudo apt-get update && sudo apt-get install -y luajit
- run: python3 -m pip install --upgrade pillow
@@ -89,7 +166,7 @@ jobs:
name: screenshot differ (capture not yet wired)
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- run: python3 -m pip install --upgrade pillow
# 21-testing-and-ci §"Testing & acceptance criteria": compare_shots
@@ -137,7 +214,7 @@ jobs:
name: mod lint (no ROM-derived content)
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
# the MK305 dump check key-diffs shipped tables through luajit, and
# modkit treats a missing interpreter as a fatal MK100 -- without
diff --git a/.github/workflows/ios-artifact-comment.yml b/.github/workflows/ios-artifact-comment.yml
new file mode 100644
index 00000000..6c8763fd
--- /dev/null
+++ b/.github/workflows/ios-artifact-comment.yml
@@ -0,0 +1,61 @@
+name: iOS artifact comment
+
+on:
+ workflow_run:
+ workflows: [ci]
+ types: [completed]
+
+permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+
+jobs:
+ comment:
+ if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
+ runs-on: ubuntu-latest
+ steps:
+ - id: artifact
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RUN_ID: ${{ github.event.workflow_run.id }}
+ HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
+ HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
+ run: |
+ artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-ios-ipa") | .id')"
+ [ -n "$artifact_id" ] || exit 0
+ head_owner="${HEAD_REPOSITORY%%/*}"
+ pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
+ [ -n "$pr_number" ] || exit 0
+ echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
+ echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
+ - name: Delete existing comment
+ if: steps.artifact.outputs.pr_number != ''
+ uses: izhangzhihao/delete-comment@master
+ with:
+ github_token: ${{ github.token }}
+ delete_user_name: github-actions[bot]
+ issue_number: ${{ steps.artifact.outputs.pr_number }}
+ - name: Get build info
+ id: build-info
+ env:
+ HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
+ run: |
+ commit_hash="$(printf '%s' "$HEAD_SHA" | cut -c1-7)"
+ build_time="$(date "+%Y-%m-%d %H:%M:%S")"
+ echo "hash=$commit_hash" >> "$GITHUB_OUTPUT"
+ echo "time=$build_time" >> "$GITHUB_OUTPUT"
+ - name: comment iOS artifact
+ if: steps.artifact.outputs.pr_number != ''
+ uses: thollander/actions-comment-pull-request@v3
+ with:
+ message: |
+ [gen1recomp.ipa](${{ steps.artifact.outputs.artifact_url }})
+
+ **Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }})
+ **Build Time**: `${{ steps.build-info.outputs.time }}`
+
+ This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})
+ pr-number: ${{ steps.artifact.outputs.pr_number }}
+ comment-tag: ios-build-result
+ github-token: ${{ github.token }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 984bff2a..e7c258ef 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -25,6 +25,7 @@ on:
paths-ignore:
- '.github/**'
- '**.md'
+ - 'mobile/ios/app-repo.json'
workflow_dispatch:
inputs:
version:
@@ -43,7 +44,7 @@ concurrency:
jobs:
release:
- runs-on: [self-hosted, macOS]
+ runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
steps:
# The self-hosted runner lives under the machine owner's home
@@ -53,7 +54,7 @@ jobs:
run: echo "::add-mask::$HOME"
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
@@ -128,6 +129,7 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Import signing certificate into a temporary keychain
+ if: github.repository == 'bryanthaboi/gen1recomp'
run: |
set -euo pipefail
KEYCHAIN_PATH="$RUNNER_TEMP/pokemon-signing.keychain-db"
@@ -183,13 +185,17 @@ jobs:
brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
- name: Build iOS
+ env:
+ CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
run: |
set -euo pipefail
- # Device Release IPA; signs with the Apple Development identity on
- # the runner (auto team detection). Users on other Apple IDs still
- # re-sign or build via docs/ios-install.md.
- scripts/build_ios.sh --fetch --device --release \
- --version "${{ steps.ver.outputs.version }}"
+ if [ "$CANONICAL_REPOSITORY" = true ]; then
+ scripts/build_ios.sh --fetch --device --release \
+ --version "${{ steps.ver.outputs.version }}"
+ else
+ scripts/build_ios.sh --fetch --release \
+ --version "${{ steps.ver.outputs.version }}"
+ fi
- name: Build Switch
run: |
@@ -207,6 +213,7 @@ jobs:
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
- name: Notarize & staple macOS app
+ if: github.repository == 'bryanthaboi/gen1recomp'
run: |
set -euo pipefail
ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}"
@@ -241,6 +248,7 @@ jobs:
echo "Notarized + stapled ✓"
- name: Stage release assets
+ if: github.repository == 'bryanthaboi/gen1recomp'
id: assets
run: |
set -euo pipefail
@@ -288,6 +296,7 @@ jobs:
cat "$outdir/sha256sums.txt"
- name: Publish GitHub Release
+ if: github.repository == 'bryanthaboi/gen1recomp'
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -400,7 +409,72 @@ jobs:
echo "Published release $tag"
+ - name: Update iOS app repository
+ if: github.repository == 'bryanthaboi/gen1recomp'
+ run: |
+ set -euo pipefail
+ v="${{ steps.ver.outputs.version }}"
+ ipa="dist/release/gen1recomp-${v}-ios.ipa"
+ app_repo="mobile/ios/app-repo.json"
+ [ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; }
+ [ -f "$app_repo" ] || { echo "::error::$app_repo not found"; exit 1; }
+
+ date="$(date -u +"%Y-%m-%d")"
+ size="$(wc -c < "$ipa" | tr -d '[:space:]')"
+ download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp-${v}-ios.ipa"
+ localized_description="Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke"
+ release_notes="$(GH_TOKEN="${{ github.token }}" gh release view "v${v}" --json body --jq '.body // ""' 2>/dev/null || true)"
+ if [ -n "$release_notes" ]; then
+ localized_description="$release_notes"
+ fi
+ entry="$(jq -n \
+ --arg version "$v" \
+ --arg date "$date" \
+ --arg download_url "$download_url" \
+ --arg localized_description "$localized_description" \
+ --argjson size "$size" \
+ '{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: $localized_description}')"
+
+ if jq -e --arg version "$v" \
+ 'any(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions[]?; .version == $version)' \
+ "$app_repo" >/dev/null; then
+ jq --arg version "$v" --argjson entry "$entry" \
+ '(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= map(if .version == $version then $entry else . end)' \
+ "$app_repo" > "$app_repo.tmp"
+ else
+ jq --argjson entry "$entry" \
+ '(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= [$entry] + .' \
+ "$app_repo" > "$app_repo.tmp"
+ fi
+ mv "$app_repo.tmp" "$app_repo"
+
+ # main is PR-only for everyone except deploy keys (the "main protection"
+ # ruleset's bypass actor), so this push must authenticate with the
+ # RELEASE_DEPLOY_KEY deploy key over SSH; the workflow's GITHUB_TOKEN
+ # would be rejected by the branch protection.
+ - name: Commit iOS app repository
+ if: github.repository == 'bryanthaboi/gen1recomp'
+ env:
+ DEPLOY_KEY: ${{ secrets.RELEASE_DEPLOY_KEY }}
+ run: |
+ set -euo pipefail
+ git add mobile/ios/app-repo.json
+ if git diff --cached --quiet; then
+ echo "app-repo.json unchanged; nothing to push"
+ exit 0
+ fi
+ key="$RUNNER_TEMP/release-deploy-key"
+ printf '%s\n' "$DEPLOY_KEY" > "$key"
+ chmod 600 "$key"
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git commit -m "chore(ios): update app-repo.json [skip ci]"
+ git -c core.sshCommand="ssh -i $key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
+ push "git@github.com:${GITHUB_REPOSITORY}.git" "HEAD:${GITHUB_REF_NAME}"
+ rm -f "$key"
+
- name: Clean up signing keychain
- if: always()
+ if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
run: |
security delete-keychain "$RUNNER_TEMP/pokemon-signing.keychain-db" 2>/dev/null || true
+ rm -f "$RUNNER_TEMP/release-deploy-key"
diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md
index d11c7a33..5f326262 100644
--- a/CONTRIBUTING-mods.md
+++ b/CONTRIBUTING-mods.md
@@ -118,6 +118,7 @@ not a hard error, so the list can grow without breaking old mods.
| `QUEST` | New story, NPCs, dialogue, cutscenes | content |
| `MECHANIC` | New or changed battle/field mechanics via hooks/effects | overhaul |
| `GRAPHICS` | Sprite / tileset / palette / font changes | content |
+| `LANGUAGE` | A translation: `text`, `strings` and the glyphs it needs | content |
| `AUDIO` | Music, sfx, cries | content |
| `UI` | New or modified screens, menus, overlays | content / overhaul |
| `TOOL` | Dev/QoL utilities, overlays, inter-mod libraries | content |
@@ -128,6 +129,15 @@ not a hard error, so the list can grow without breaking old mods.
keeps validating with the value it has shipped since before the taxonomy
existed.
+A translation may also set `"language": true` in the manifest. That is the
+one claim online play acts on: an install running nothing but verified
+translations may take an ONLINE MATCH or a TOURNAMENT instead of being
+asked to restart vanilla. The claim is checked, not taken -- the mod
+qualifies only if every record it writes lands in `text`, `strings` or
+`font`, it wraps no hook, subscribes to no event and requests no
+permission. Anything else and it is an ordinary content mod that happens to
+ship text.
+
### 4. `mod.card`
The manifest is the *engine's* contract: identity, load order, dependencies,
diff --git a/README.md b/README.md
index bbc9ec5d..1dffafb6 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,9 @@ A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map
behavior are hand-written Lua; game data and graphics are decoded from a ROM
supplied by the player.
+> [!CAUTION]
+> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project.
+

**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
@@ -75,6 +78,19 @@ The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact
audio channel programs copied out of the verified ROM.
+### A note on Windows Defender warnings
+
+Windows Defender sometimes flags the Windows build with a generic
+machine-learning detection such as `Trojan:Win32/Wacatac!ml` (#621). This is
+a known false positive: the exe is the official LÖVE runtime with the game
+archive appended (the standard way LÖVE games ship), and Defender's
+heuristics distrust unsigned executables with appended data. Every release
+publishes SHA-256 checksums (`sha256sums.txt`) so you can verify your
+download, and you can confirm a flagged file yourself on
+[VirusTotal](https://www.virustotal.com), where these builds come back clean
+on every engine except Defender's heuristic. False positives are reported to
+Microsoft as they come up.
+
## Controls
@@ -96,6 +112,7 @@ supported out of the box.
| Key | What it does |
| --------- | ---------------------------------------------------- |
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
+| `1` | Cycle GAME SPEED up (controller: R2 faster, L2 slower) |
| `2` | Cycle COLORS |
| `3` | Cycle TILT (free-roam overworld) |
| `4` | Cycle ZOOM through every level (free-roam overworld) |
@@ -105,8 +122,8 @@ supported out of the box.
| `F10` | Open / close the mod manager |
-COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
-and persist in `options.lua`.
+COLORS, TILT, ZOOM, GBC FX, GAME SPEED, and VOID FILL are also in the
+Options menu and persist in `options.lua`.
### Low-end devices
@@ -195,6 +212,16 @@ Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
build and install from source on a Mac instead, see
[docs/ios-install.md](docs/ios-install.md).
+
+
## Handhelds
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
diff --git a/data/palettes_gbc.lua b/data/palettes_gbc.lua
index 5c98c88b..66a66e19 100644
--- a/data/palettes_gbc.lua
+++ b/data/palettes_gbc.lua
@@ -1683,158 +1683,172 @@ return {
{ 0, 0, 0 },
},
},
+ -- Species -> palette NAME, taken from pokered-gbc data/pokemon/palettes.asm
+ -- ELSE branch -- the one that is NOT gated on GEN_2_GRAPHICS.
+ --
+ -- That file has two tables. The IF GEN_2_GRAPHICS branch assigns a
+ -- PER-SPECIES palette (PAL_BULBASAUR, PAL_SQUIRTLE, ...) authored for Gen 2
+ -- sprite art; the ELSE branch keeps Gen 1's own assignments (GREENMON,
+ -- CYANMON, ...). This port extracts Gen 1 pics from the ROM, so the Gen 1
+ -- branch is the matching one. The per-species table was imported here by
+ -- mistake, which pointed every mon at colours shaded for different art:
+ -- Bulbasaur wore PAL_BULBASAUR's red-orange over a sprite that has no red
+ -- on it, and Squirtle wore PAL_SQUIRTLE's shell brown on his head.
+ --
+ -- The palette VALUES below are still pokered-gbc's, so ADVANCED keeps its
+ -- richer colours -- only which palette each species points at changed.
pokemon = {
- ABRA = "ABRA",
- AERODACTYL = "AERODACTYL",
- ALAKAZAM = "ALAKAZAM",
- ARBOK = "ARBOK",
- ARCANINE = "ARCANINE",
- ARTICUNO = "ARTICUNO",
- BEEDRILL = "BEEDRILL",
- BELLSPROUT = "BELLSPROUT",
- BLASTOISE = "BLASTOISE",
- BULBASAUR = "BULBASAUR",
- BUTTERFREE = "BUTTERFREE",
- CATERPIE = "CATERPIE",
- CHANSEY = "CHANSEY",
- CHARIZARD = "CHARIZARD",
- CHARMANDER = "CHARMANDER",
- CHARMELEON = "CHARMELEON",
- CLEFABLE = "CLEFABLE",
- CLEFAIRY = "CLEFAIRY",
- CLOYSTER = "CLOYSTER",
- CUBONE = "CUBONE",
- DEWGONG = "DEWGONG",
- DIGLETT = "DIGLETT",
- DITTO = "DITTO",
- DODRIO = "DODRIO",
- DODUO = "DODUO",
- DRAGONAIR = "DRAGONAIR",
- DRAGONITE = "DRAGONITE",
- DRATINI = "DRATINI",
- DROWZEE = "DROWZEE",
- DUGTRIO = "DUGTRIO",
- EEVEE = "EEVEE",
- EKANS = "EKANS",
- ELECTABUZZ = "ELECTABUZZ",
- ELECTRODE = "ELECTRODE",
- EXEGGCUTE = "EXEGGCUTE",
- EXEGGUTOR = "EXEGGUTOR",
- FARFETCHD = "FARFETCH_D",
- FEAROW = "FEAROW",
- FLAREON = "FLAREON",
- GASTLY = "GASTLY",
- GENGAR = "GENGAR",
- GEODUDE = "GEODUDE",
- GLOOM = "GLOOM",
- GOLBAT = "GOLBAT",
- GOLDEEN = "GOLDEEN",
- GOLDUCK = "GOLDUCK",
- GOLEM = "GOLEM",
- GRAVELER = "GRAVELER",
- GRIMER = "GRIMER",
- GROWLITHE = "GROWLITHE",
- GYARADOS = "GYARADOS",
- HAUNTER = "HAUNTER",
- HITMONCHAN = "HITMONCHAN",
- HITMONLEE = "HITMONLEE",
- HORSEA = "HORSEA",
- HYPNO = "HYPNO",
- IVYSAUR = "IVYSAUR",
- JIGGLYPUFF = "JIGGLYPUFF",
- JOLTEON = "JOLTEON",
- JYNX = "JYNX",
- KABUTO = "KABUTO",
- KABUTOPS = "KABUTOPS",
- KADABRA = "KADABRA",
- KAKUNA = "KAKUNA",
- KANGASKHAN = "KANGASKHAN",
- KINGLER = "KINGLER",
- KOFFING = "KOFFING",
- KRABBY = "KRABBY",
- LAPRAS = "LAPRAS",
- LICKITUNG = "LICKITUNG",
- MACHAMP = "MACHAMP",
- MACHOKE = "MACHOKE",
- MACHOP = "MACHOP",
- MAGIKARP = "MAGIKARP",
- MAGMAR = "MAGMAR",
- MAGNEMITE = "MAGNEMITE",
- MAGNETON = "MAGNETON",
- MANKEY = "MANKEY",
- MAROWAK = "MAROWAK",
- MEOWTH = "MEOWTH",
- METAPOD = "METAPOD",
- MEW = "MEW",
- MEWTWO = "MEWTWO",
- MOLTRES = "MOLTRES",
- MR_MIME = "MR_MIME",
- MUK = "MUK",
- NIDOKING = "NIDOKING",
- NIDOQUEEN = "NIDOQUEEN",
- NIDORAN_F = "NIDORAN_F",
- NIDORAN_M = "NIDORAN_M",
- NIDORINA = "NIDORINA",
- NIDORINO = "NIDORINO",
- NINETALES = "NINETALES",
- ODDISH = "ODDISH",
- OMANYTE = "OMANYTE",
- OMASTAR = "OMASTAR",
- ONIX = "ONIX",
- PARAS = "PARAS",
- PARASECT = "PARASECT",
- PERSIAN = "PERSIAN",
- PIDGEOT = "PIDGEOT",
- PIDGEOTTO = "PIDGEOTTO",
- PIDGEY = "PIDGEY",
- PIKACHU = "PIKACHU",
- PINSIR = "PINSIR",
- POLIWAG = "POLIWAG",
- POLIWHIRL = "POLIWHIRL",
- POLIWRATH = "POLIWRATH",
- PONYTA = "PONYTA",
- PORYGON = "PORYGON",
- PRIMEAPE = "PRIMEAPE",
- PSYDUCK = "PSYDUCK",
- RAICHU = "RAICHU",
- RAPIDASH = "RAPIDASH",
- RATICATE = "RATICATE",
- RATTATA = "RATTATA",
- RHYDON = "RHYDON",
- RHYHORN = "RHYHORN",
- SANDSHREW = "SANDSHREW",
- SANDSLASH = "SANDSLASH",
- SCYTHER = "SCYTHER",
- SEADRA = "SEADRA",
- SEAKING = "SEAKING",
- SEEL = "SEEL",
- SHELLDER = "SHELLDER",
- SLOWBRO = "SLOWBRO",
- SLOWPOKE = "SLOWPOKE",
- SNORLAX = "SNORLAX",
- SPEAROW = "SPEAROW",
- SQUIRTLE = "SQUIRTLE",
- STARMIE = "STARMIE",
- STARYU = "STARYU",
- TANGELA = "TANGELA",
- TAUROS = "TAUROS",
- TENTACOOL = "TENTACOOL",
- TENTACRUEL = "TENTACRUEL",
- VAPOREON = "VAPOREON",
- VENOMOTH = "VENOMOTH",
- VENONAT = "VENONAT",
- VENUSAUR = "VENUSAUR",
- VICTREEBEL = "VICTREEBEL",
- VILEPLUME = "VILEPLUME",
- VOLTORB = "VOLTORB",
- VULPIX = "VULPIX",
- WARTORTLE = "WARTORTLE",
- WEEDLE = "WEEDLE",
- WEEPINBELL = "WEEPINBELL",
- WEEZING = "WEEZING",
- WIGGLYTUFF = "WIGGLYTUFF",
- ZAPDOS = "ZAPDOS",
- ZUBAT = "ZUBAT",
+ ABRA = "YELLOWMON",
+ AERODACTYL = "GRAYMON",
+ ALAKAZAM = "YELLOWMON",
+ ARBOK = "PURPLEMON",
+ ARCANINE = "REDMON",
+ ARTICUNO = "BLUEMON",
+ BEEDRILL = "YELLOWMON",
+ BELLSPROUT = "GREENMON",
+ BLASTOISE = "CYANMON",
+ BULBASAUR = "GREENMON",
+ BUTTERFREE = "CYANMON",
+ CATERPIE = "GREENMON",
+ CHANSEY = "PINKMON",
+ CHARIZARD = "REDMON",
+ CHARMANDER = "REDMON",
+ CHARMELEON = "REDMON",
+ CLEFABLE = "PINKMON",
+ CLEFAIRY = "PINKMON",
+ CLOYSTER = "GRAYMON",
+ CUBONE = "GRAYMON",
+ DEWGONG = "BLUEMON",
+ DIGLETT = "BROWNMON",
+ DITTO = "GRAYMON",
+ DODRIO = "BROWNMON",
+ DODUO = "BROWNMON",
+ DRAGONAIR = "BLUEMON",
+ DRAGONITE = "BROWNMON",
+ DRATINI = "GRAYMON",
+ DROWZEE = "YELLOWMON",
+ DUGTRIO = "BROWNMON",
+ EEVEE = "GRAYMON",
+ EKANS = "PURPLEMON",
+ ELECTABUZZ = "YELLOWMON",
+ ELECTRODE = "YELLOWMON",
+ EXEGGCUTE = "PINKMON",
+ EXEGGUTOR = "GREENMON",
+ FARFETCHD = "BROWNMON",
+ FEAROW = "BROWNMON",
+ FLAREON = "REDMON",
+ GASTLY = "PURPLEMON",
+ GENGAR = "PURPLEMON",
+ GEODUDE = "GRAYMON",
+ GLOOM = "REDMON",
+ GOLBAT = "BLUEMON",
+ GOLDEEN = "REDMON",
+ GOLDUCK = "CYANMON",
+ GOLEM = "GRAYMON",
+ GRAVELER = "GRAYMON",
+ GRIMER = "PURPLEMON",
+ GROWLITHE = "BROWNMON",
+ GYARADOS = "BLUEMON",
+ HAUNTER = "PURPLEMON",
+ HITMONCHAN = "BROWNMON",
+ HITMONLEE = "BROWNMON",
+ HORSEA = "CYANMON",
+ HYPNO = "YELLOWMON",
+ IVYSAUR = "GREENMON",
+ JIGGLYPUFF = "PINKMON",
+ JOLTEON = "YELLOWMON",
+ JYNX = "MEWMON",
+ KABUTO = "BROWNMON",
+ KABUTOPS = "BROWNMON",
+ KADABRA = "YELLOWMON",
+ KAKUNA = "YELLOWMON",
+ KANGASKHAN = "BROWNMON",
+ KINGLER = "REDMON",
+ KOFFING = "PURPLEMON",
+ KRABBY = "REDMON",
+ LAPRAS = "CYANMON",
+ LICKITUNG = "PINKMON",
+ MACHAMP = "GRAYMON",
+ MACHOKE = "GRAYMON",
+ MACHOP = "GRAYMON",
+ MAGIKARP = "REDMON",
+ MAGMAR = "REDMON",
+ MAGNEMITE = "GRAYMON",
+ MAGNETON = "GRAYMON",
+ MANKEY = "BROWNMON",
+ MAROWAK = "GRAYMON",
+ MEOWTH = "YELLOWMON",
+ METAPOD = "GREENMON",
+ MEW = "MEWMON",
+ MEWTWO = "MEWMON",
+ MOLTRES = "REDMON",
+ MR_MIME = "PINKMON",
+ MUK = "PURPLEMON",
+ NIDOKING = "PURPLEMON",
+ NIDOQUEEN = "BLUEMON",
+ NIDORAN_F = "BLUEMON",
+ NIDORAN_M = "PURPLEMON",
+ NIDORINA = "BLUEMON",
+ NIDORINO = "PURPLEMON",
+ NINETALES = "YELLOWMON",
+ ODDISH = "GREENMON",
+ OMANYTE = "BLUEMON",
+ OMASTAR = "BLUEMON",
+ ONIX = "GRAYMON",
+ PARAS = "REDMON",
+ PARASECT = "REDMON",
+ PERSIAN = "YELLOWMON",
+ PIDGEOT = "BROWNMON",
+ PIDGEOTTO = "BROWNMON",
+ PIDGEY = "BROWNMON",
+ PIKACHU = "YELLOWMON",
+ PINSIR = "BROWNMON",
+ POLIWAG = "BLUEMON",
+ POLIWHIRL = "BLUEMON",
+ POLIWRATH = "BLUEMON",
+ PONYTA = "REDMON",
+ PORYGON = "GRAYMON",
+ PRIMEAPE = "BROWNMON",
+ PSYDUCK = "YELLOWMON",
+ RAICHU = "YELLOWMON",
+ RAPIDASH = "REDMON",
+ RATICATE = "GRAYMON",
+ RATTATA = "GRAYMON",
+ RHYDON = "GRAYMON",
+ RHYHORN = "GRAYMON",
+ SANDSHREW = "BROWNMON",
+ SANDSLASH = "BROWNMON",
+ SCYTHER = "GREENMON",
+ SEADRA = "CYANMON",
+ SEAKING = "REDMON",
+ SEEL = "BLUEMON",
+ SHELLDER = "GRAYMON",
+ SLOWBRO = "PINKMON",
+ SLOWPOKE = "PINKMON",
+ SNORLAX = "PINKMON",
+ SPEAROW = "BROWNMON",
+ SQUIRTLE = "CYANMON",
+ STARMIE = "GRAYMON",
+ STARYU = "REDMON",
+ TANGELA = "BLUEMON",
+ TAUROS = "GRAYMON",
+ TENTACOOL = "CYANMON",
+ TENTACRUEL = "CYANMON",
+ VAPOREON = "CYANMON",
+ VENOMOTH = "PURPLEMON",
+ VENONAT = "PURPLEMON",
+ VENUSAUR = "GREENMON",
+ VICTREEBEL = "GREENMON",
+ VILEPLUME = "REDMON",
+ VOLTORB = "YELLOWMON",
+ VULPIX = "REDMON",
+ WARTORTLE = "CYANMON",
+ WEEDLE = "YELLOWMON",
+ WEEPINBELL = "GREENMON",
+ WEEZING = "PURPLEMON",
+ WIGGLYTUFF = "PINKMON",
+ ZAPDOS = "YELLOWMON",
+ ZUBAT = "BLUEMON",
},
source = "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm + color/**",
world = {
diff --git a/data/scripts/flavor/viridian_school_house.lua b/data/scripts/flavor/viridian_school_house.lua
index cb690ffa..e6b0f214 100644
--- a/data/scripts/flavor/viridian_school_house.lua
+++ b/data/scripts/flavor/viridian_school_house.lua
@@ -9,7 +9,8 @@
-- hidden_text_predef spends the facing byte on the tx_pre id, so neither
-- tile gates on facing.
-local Menu = require("src.ui.Menu")
+local Font = require("src.render.Font")
+local Theme = require("src.ui.Theme")
local TextBox = require("src.render.TextBox")
-- ViridianSchoolBlackboard (engine/events/hidden_events/school_blackboard.asm):
@@ -25,33 +26,112 @@ local STATUS_LABELS = {
{ " FRZ", "_ViridianBlackboardFrozenText" },
}
+-- The headings list is a two-column menu, which src/ui/Menu.lua does not do
+-- (it stacks one column), so the layout lives here (#591). .blackboardLoop:
+-- TextBoxBorder at hlcoord 0, 0 with `lb bc, 6, 10` is the 12x8 box,
+-- StatusAilmentText1 (" SLP"/" PSN"/" PAR") is placed at hlcoord 1, 2 and
+-- StatusAilmentText2 (" BRN"/" FRZ"/" QUIT") at hlcoord 6, 2. LEFT/RIGHT
+-- move wTopMenuItemX between those two columns and swap wMenuItemOffset
+-- between 0 and 3 while leaving wCurrentMenuItem (the row) alone; UP/DOWN
+-- are not in wMenuWatchedKeys, so they only slide the cursor and loop.
+local BOARD_LABELS = {}
+for i, row in ipairs(STATUS_LABELS) do BOARD_LABELS[i] = row[1] end
+BOARD_LABELS[#BOARD_LABELS + 1] = " QUIT"
+local BOARD_COL_X = { 1, 6 }
+local BOARD_ROW_Y = 2
+local BOARD_ROWS = 3
+
+local StatusBoard = {}
+StatusBoard.__index = StatusBoard
+
+function StatusBoard.new(game, onPick, onQuit)
+ return setmetatable({ game = game, col = 1, row = 1, labels = BOARD_LABELS,
+ onPick = onPick, onQuit = onQuit }, StatusBoard)
+end
+
+-- flat index = pokered's wMenuItemOffset (0 or 3) + wCurrentMenuItem (0..2),
+-- so 1..5 are the statuses in ViridianBlackboardStatusPointers order and 6
+-- is QUIT
+function StatusBoard:selection()
+ return (self.col - 1) * BOARD_ROWS + self.row
+end
+
+function StatusBoard:update()
+ local input = self.game.input
+ if input:wasPressed("up") then
+ -- wMenuWrappingEnabled is never set here, so both ends are hard stops
+ if self.row > 1 then self.row = self.row - 1 end
+ elseif input:wasPressed("down") then
+ if self.row < BOARD_ROWS then self.row = self.row + 1 end
+ elseif input:wasPressed("left") then
+ self.col = 1
+ elseif input:wasPressed("right") then
+ self.col = 2
+ elseif input:wasPressed("a") or input:wasPressed("b") then
+ -- HandleMenuInput_ (home/window.asm) beeps for the PAD_A | PAD_B branch,
+ -- and B and QUIT share .exitBlackboard
+ require("src.core.Sound").play(self.game.data, "Press_AB")
+ local sel = self:selection()
+ if input:wasPressed("b") or sel > #STATUS_LABELS then
+ self.onQuit()
+ else
+ self.onPick(sel)
+ end
+ end
+end
+
+function StatusBoard:draw()
+ Font.drawBox(0, 0, 12, 8)
+ love.graphics.setColor(0, 0, 0, 1)
+ for i, label in ipairs(BOARD_LABELS) do
+ local col = i <= BOARD_ROWS and 1 or 2
+ local row = i - (col - 1) * BOARD_ROWS
+ Font.draw(label, BOARD_COL_X[col] * 8, (BOARD_ROW_Y + row - 1) * 8)
+ end
+ -- wTopMenuItemX equals the column PlaceString started at, so the cursor
+ -- covers the blank each label leads with
+ Font.drawCode(Theme.cursor, BOARD_COL_X[self.col] * 8,
+ (BOARD_ROW_Y + self.row - 1) * 8)
+ love.graphics.setColor(1, 1, 1, 1)
+end
+
local function blackboard(game)
local text = game.data.text or {}
- local items, showMenu, askHeading
- function showMenu()
- game.stack:push(Menu.new(game, items,
- { tx = 0, ty = 0, tw = 12, th = 8, rowStep = 1 }))
+ local openBoard
+ -- wCurrentMenuItem / wMenuItemOffset are zeroed once, above .blackboardLoop,
+ -- and nothing inside the loop clears them again: after a status blurb
+ -- `jp .blackboardLoop` comes back with the cursor still on the row and
+ -- column the player just picked. One StatusBoard lives for the whole
+ -- reading and is re-pushed each pass, so only entering the blackboard
+ -- resets to the left column / top row (#591).
+ local board
+ -- .blackboardLoop reprints ViridianSchoolBlackboardText2 and only then
+ -- calls HandleMenuInput, so the prompt is on screen for exactly as long as
+ -- the headings list is. That text ends in `done`, not `prompt`
+ -- (data/text/text_2.asm:646), so PrintText returns with the box still up
+ -- and never waits for a button: TextBox opts.stay holds it open under the
+ -- list and these callbacks pop the pair together (#591).
+ local function closeBoard()
+ game.stack:pop() -- the headings list
+ game.stack:pop() -- the held "Which heading" box under it
end
- -- ViridianSchoolBlackboardText2 is reprinted on every .blackboardLoop
- -- pass, immediately before HandleMenuInput
- function askHeading()
+ local function pick(i)
+ closeBoard()
+ game.stack:push(TextBox.new(game,
+ text[STATUS_LABELS[i][2]] or STATUS_LABELS[i][1], openBoard))
+ end
+ function openBoard()
game.stack:push(TextBox.new(game,
text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?",
- showMenu))
+ nil, { stay = { onShown = function()
+ board = board or StatusBoard.new(game, pick, closeBoard)
+ game.stack:push(board)
+ end } }))
end
- items = {}
- for i, row in ipairs(STATUS_LABELS) do
- local label, key = row[1], row[2]
- items[i] = { label = label, onSelect = function()
- game.stack:push(TextBox.new(game, text[key] or label, askHeading))
- end }
- end
- -- no onSelect: Menu's own pop closes the box, matching .exitBlackboard
- items[#items + 1] = { label = " QUIT" }
game.stack:push(TextBox.new(game,
text._ViridianSchoolBlackboardText1
or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.",
- askHeading))
+ openBoard))
end
-- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm):
diff --git a/data/scripts/init.lua b/data/scripts/init.lua
index b6a214c3..42fe74e6 100644
--- a/data/scripts/init.lua
+++ b/data/scripts/init.lua
@@ -48,7 +48,8 @@ end
if GameVersion.isYellow() then
for _, file in ipairs({ "data.scripts.yellow_gifts",
"data.scripts.yellow_jessie_james",
- "data.scripts.yellow_beach_house" }) do
+ "data.scripts.yellow_beach_house",
+ "data.scripts.yellow_viridian_old_man" }) do
for mapId, mod in pairs(require(file)) do
MapScripts.attachBase(mapId, mod)
end
diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua
index 82ebb4bb..afc9fc4c 100644
--- a/data/scripts/oaks_lab.lua
+++ b/data/scripts/oaks_lab.lua
@@ -22,10 +22,10 @@ local function starterBall(askText, species, choseFlag, ownBall,
rivalBallX, rivalBall)
return {
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1
- { "jump_if_true", 20 }, -- 2
+ { "jump_if_true", 22 }, -- 2
-- no picking until Oak has walked you in (OaksLabScript gating)
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
- { "jump_if_false", 22 }, -- 4
+ { "jump_if_false", 25 }, -- 4
-- the Pokédex "new species" entry shows before the ask (predef
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
-- sets the owned bits so ShowPokedexData prints height/weight/text;
@@ -37,33 +37,41 @@ local function starterBall(askText, species, choseFlag, ownBall,
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
-- inside give_pokemon). Show the received text first so the
-- nickname prompt follows "you got X", matching Gen1.
- { "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
- { "give_pokemon", species, 5 }, -- 9
- { "set_flag", "EVENT_GOT_STARTER" }, -- 10
- { "set_flag", choseFlag }, -- 11
+ -- The received text carries sound_get_key_item (OaksLab.asm
+ -- OaksLabReceivedMonText); the jingle plays as the box opens
+ -- (same beat as the Yellow port's starter, #668).
+ { "play_sound", "Get_Key_Item" }, -- 8
+ { "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
+ { "give_pokemon", species, 5 }, -- 10
+ { "set_flag", "EVENT_GOT_STARTER" }, -- 11
+ { "set_flag", choseFlag }, -- 12
-- POKé BALLs are not handed out here in the original -- Oak gives
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
- { "hide_object", "OAKS_LAB", ownBall }, -- 12
+ { "hide_object", "OAKS_LAB", ownBall }, -- 13
-- the rival walks to the countering ball (around the furniture)
- { "move_npc_to", 1, rivalBallX, 4 }, -- 13
- { "face_object", 1, "up" }, -- 14
- { "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 15
- { "hide_object", "OAKS_LAB", rivalBall }, -- 16
+ { "move_npc_to", 1, rivalBallX, 4 }, -- 14
+ { "face_object", 1, "up" }, -- 15
+ { "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
+ { "hide_object", "OAKS_LAB", rivalBall }, -- 17
+ { "play_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
{ "show_text", "_OaksLabRivalReceivedMonText",
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
- or "BULBASAUR" } }, -- 17
- { "jump", "end" }, -- 18
- { "jump", "end" }, -- 19 (spacer)
+ or "BULBASAUR" } }, -- 19
+ { "jump", "end" }, -- 20
+ { "jump", "end" }, -- 21 (spacer)
-- a leftover ball after the player's pick: Oak turns to face the
-- player and reads the last-mon line instead of re-offering the
-- starter (scripts/OaksLab.asm OaksLabSelectedPokeBallScript ->
-- OaksLabLastMonScript; #601). The ROM's "#MON" ligature is spelled
-- out as Pokémon here.
- { "face_object", 5, "down" }, -- 20
- { "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 21
- { "show_text", "_OaksLabThoseArePokeBallsText" }, -- 22
+ { "face_object", 5, "down" }, -- 22
+ { "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 23
+ -- OaksLabLastMonScript ends at TextScriptEnd; the port used to fall
+ -- through into the pre-pick line below (#601 remnant, reported on #600)
+ { "jump", "end" }, -- 24
+ { "show_text", "_OaksLabThoseArePokeBallsText" }, -- 25
}
end
@@ -71,10 +79,22 @@ return {
talk = {
-- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_
-- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk-
- -- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating
- -- (DisplayDexRating) is still skipped.
+ -- in, full Pokédex speech, rival exit, Route 22 arm).
TEXT_OAKSLAB_OAK1 = {
{ "face_player" },
+ -- OaksLabOak1Text leads with the dex-rating branch (#600): with
+ -- EVENT_PALLET_AFTER_GETTING_POKEBALLS set (converted saves), or
+ -- 2+ species owned once the Pokédex is in hand, Oak asks how it is
+ -- coming and rates it (predef DisplayDexRating). Red keeps the
+ -- GOT_POKEDEX gate that Yellow's copy of this text drops
+ -- (data/scripts/oaks_lab_yellow.lua).
+ { "check_flag", "EVENT_PALLET_AFTER_GETTING_POKEBALLS" },
+ { "jump_if_true", "dex_rating" },
+ { "check_dex_owned", 2 },
+ { "jump_if_false", "no_rating" },
+ { "check_flag", "EVENT_GOT_POKEDEX" },
+ { "jump_if_true", "dex_rating" },
+ { "label", "no_rating" },
{ "check_item", "POKE_BALL" },
{ "jump_if_true", "come_see" },
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
@@ -160,6 +180,14 @@ return {
{ "label", "come_see" },
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" },
+ { "jump", "end" },
+
+ -- .HowIsYourPokedexComingText ends on `prompt` and OaksLabOak1Text
+ -- sets wDoNotWaitForButtonPressAfterDisplayingText, so the seen/owned
+ -- tally follows with no button wait (engine/events/pokedex_rating.asm)
+ { "label", "dex_rating" },
+ { "show_text", "_OaksLabOak1HowIsYourPokedexComingText" },
+ { "dex_rating" },
},
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
@@ -254,7 +282,12 @@ return {
and y >= 6 then
local rival = ow:npcByIndex(1)
if not rival then return false end
+ -- OaksLabRivalChallengesPlayerScript swaps in the rival encounter
+ -- fanfare for the taunt/challenge exchange, same as the Yellow port
+ -- (oaks_lab_yellow.lua); it was silently dropped here (#596).
local rows = {
+ { "stop_music" },
+ { "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
}
-- the rival routes to a free cell beside the player
@@ -292,8 +325,15 @@ return {
table.insert(rows, { "jump_if_false", base + 6 })
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
+ -- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
+ -- walk out past the player. The fanfare was dropped here (#683) -- the
+ -- parcel scene above already plays Music_MeetRival on both arrival and
+ -- departure (lines 144-146), and this exit should match (#596).
+ table.insert(rows, { "stop_music" })
+ table.insert(rows, { "play_music", "Music_MeetRival" })
table.insert(rows, { "move_npc_to", 1, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
+ table.insert(rows, { "play_music", "Music_OaksLab" })
ow.runner:run(rows, { npc = rival })
return true
end
diff --git a/data/scripts/oaks_lab_yellow.lua b/data/scripts/oaks_lab_yellow.lua
index e2c7b158..8f4ac856 100644
--- a/data/scripts/oaks_lab_yellow.lua
+++ b/data/scripts/oaks_lab_yellow.lua
@@ -71,8 +71,12 @@ return {
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" },
{ "set_flag", "EVENT_GOT_POKEDEX" },
{ "set_flag", "EVENT_OAK_GOT_PARCEL" },
+ -- OaksLabOakGivesPokedexScript: HideObject TOGGLE_LYING_OLD_MAN /
+ -- ShowObject TOGGLE_OLD_MAN_2 -- Yellow's tutorial old man stands
+ -- on the sleeper's cell (18,9); the Red/Blue walker OLD_MAN at
+ -- (17,5) never appears in Yellow (#617)
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" },
- { "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" },
+ { "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN2" },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "move_npc_to", RIVAL, 4, 7 },
@@ -283,10 +287,12 @@ return {
table.insert(rows, { "label", "lost_lab" })
table.insert(rows, { "set_field", "rivalStarter", 3 })
table.insert(rows, { "label", "exit" })
- -- OaksLabRivalStartsExitScript: parting shot, walk out past the
- -- player, restore the lab theme
+ -- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
+ -- walk out past the player (#683).
table.insert(rows, { "wait", 20 })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
+ table.insert(rows, { "stop_music" })
+ table.insert(rows, { "play_music", "Music_MeetRival" })
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
table.insert(rows, { "play_music", "Music_OaksLab" })
diff --git a/data/scripts/safari.lua b/data/scripts/safari.lua
index 3cc2ce3a..782f9f1c 100644
--- a/data/scripts/safari.lua
+++ b/data/scripts/safari.lua
@@ -4,9 +4,11 @@
-- (.PlayerNextToSafariZoneWorker1CoordsArray). Paying ¥500 hands over
-- 30 SAFARI BALLs and starts the 502-step game
-- (SafariZoneGateWouldYouLikeToJoinScript: wSafariSteps = 502,
--- wNumSafariBalls = SAFARI_BALLS_RECEIVED). Declining walks you back
--- so you can't slip past. Returning to the gate ends the game and the
--- worker takes the leftover balls back.
+-- wNumSafariBalls = SAFARI_BALLS_RECEIVED), then auto-walks the player
+-- up through the north warp into the zone. Declining walks you back
+-- so you can't slip past. Returning to the gate ends the game, the
+-- worker takes the leftover balls back and the auto-walk drops you 3
+-- cells below the warp you came in by (#540).
--
-- Step/ball bookkeeping lives in src/world/OverworldController.lua
-- (safariStep/safariGameOver, from
@@ -19,7 +21,33 @@ local FEE = 500
local BALLS = 30
local STEPS = 502
-local function startGame(game, t, done, balls, introText)
+-- SafariZoneGateSafariZoneWorker1WouldYouLikeToJoinText .success closes with
+-- `ld a, PAD_UP / ld c, 3 / SafariZoneEntranceAutoWalk`: paying walks the
+-- player up out of the gate and through the north warp, it is never left to
+-- the player. EVENT_IN_SAFARI_ZONE is already set when that walk runs, so
+-- the two gate steps taken before the warp fires are charged against
+-- wSafariSteps (home/overworld.asm:307-310) -- which is why the counter
+-- reads 500/500 on arrival even though the script wrote 502 (#540). The
+-- port's counter only runs on the nine interior maps (FieldDefaults
+-- safari.stepMaps, OverworldState:inSafariStepZone), so charge those two
+-- steps here instead.
+local function walkIntoZone(game, ow)
+ local p = ow.player
+ -- only from the two trigger cells in front of the worker, which are the
+ -- columns the north warps sit on; a player who paid after TALKING to him
+ -- from somewhere else walks in on their own, as they do today
+ local w = p.cellY == 2 and ow.map:warpAtCell(p.cellX, 0) or nil
+ if not w then return end
+ ow:scriptMove(p, "up", 2, function()
+ local st = game.save.safari
+ if st then st.steps = st.steps - 2 end
+ -- scripted steps skip onStepComplete (and with it CheckWarpsNoCollision),
+ -- so take that warp explicitly once the walk lands on it
+ ow:takeWarp(w.def)
+ end)
+end
+
+local function startGame(game, ow, t, done, balls, introText)
game.save.safari = { balls = balls or BALLS, steps = STEPS }
game.save.safariNags = nil
local TextBox = require("src.render.TextBox")
@@ -31,7 +59,10 @@ local function startGame(game, t, done, balls, introText)
local pa = t._SafariZoneGateSafariZoneWorker1CallYouOnThePAText
or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!"
local luck = t._SafariZoneGateSafariZoneWorker1GoodLuckText or "Good Luck!"
- game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, done))
+ game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, function()
+ if done then done() end
+ walkIntoZone(game, ow)
+ end))
end
-- Yellow's soft-lock fix (scripts/SafariZoneGate_2.asm): a player short of
@@ -52,7 +83,7 @@ local function yellowLowCost(game, ow, t, done, back)
or "\fOh, all right, pay\nme what you have.")
.. "\f" .. (t._SafariZoneLowCostText2
or "But, I can't give\nyou all 30 BALLs.")
- startGame(game, t, done, balls, intro)
+ startGame(game, ow, t, done, balls, intro)
return
end
local nag = game.save.safariNags or 0
@@ -62,7 +93,7 @@ local function yellowLowCost(game, ow, t, done, back)
(t._SafariZoneLowCostText8 or "Read my lips, NO!\nGet it?")
.. (t._SafariZoneLowCostText3
or "\fYou're persistent,\naren't you?\fOK, you can go in\nfor free, but\njust this once!")
- startGame(game, t, done, 1, intro)
+ startGame(game, ow, t, done, 1, intro)
return
end
local nags = {
@@ -100,7 +131,7 @@ local function joinPrompt(game, ow, done)
end
else
game.save.money = game.save.money - FEE
- startGame(game, t, done)
+ startGame(game, ow, t, done)
end
end))
end))
@@ -135,27 +166,38 @@ M.SAFARI_ZONE_GATE = {
-- no walks you back into the zone
onEnter = function(game, ow)
if not game.save.safari or ow.player.cellY > 1 then return end
- local TextBox = require("src.render.TextBox")
- local ChoiceBox = require("src.ui.ChoiceBox")
- local t = game.data.text
- game.stack:push(TextBox.new(game,
- t._SafariZoneGateSafariZoneWorker1LeavingEarlyText or "Leaving early?",
- function()
- game.stack:push(ChoiceBox.new(game, function(yes)
- if not yes then
- -- back into the zone through the entrance warp
- local w = game.data.maps.SAFARI_ZONE_CENTER.warps[1]
- ow:startWarpTo("SAFARI_ZONE_CENTER", w.x, w.y, "up")
- return
- end
- game.save.safari = nil
- game.stack:push(TextBox.new(game,
- (t._SafariZoneGateSafariZoneWorker1ReturnSafariBallsText
- or "Please return any\nSAFARI BALLs you\nhave left.")
- .. "\f" .. (t._SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText
- or "Did you get a\ngood haul?\fCome again!")))
- end))
- end))
+ -- QUEUED, never pushed: onEnter runs inside the arriving warp's
+ -- Transition midpoint, and Transition:finish pops whatever is on top
+ -- the same frame (Timing.WARP_FADE_IN is 0) -- so a box pushed here is
+ -- swallowed, and on a build where it survived it drew over a screen
+ -- still faded to black (#540). Same contract as M.HALL_OF_FAME in
+ -- data/scripts/story.lua.
+ --
+ -- SafariZoneGateLeavingSafariScript .leaving_early: YES prints the
+ -- return-balls text, faces the player down and runs
+ -- SafariZoneEntranceAutoWalk with `PAD_DOWN, c = 3`, landing on the
+ -- counter row 3 cells below the warp you came in by; NO prints
+ -- "Good Luck!" and walks one step back up through that same warp.
+ local rightSide = ow.player.cellX ~= 3
+ local dest = game.data.maps.SAFARI_ZONE_CENTER.warps[rightSide and 2 or 1]
+ ow:queueScript({
+ { "ask", "_SafariZoneGateSafariZoneWorker1LeavingEarlyText" },
+ { "jump_if_false", "stay" },
+ -- the port never reaches SafariZoneGateLeavingSafariScript's own
+ -- GOOD_HAUL_COME_AGAIN branch (safariGameOver warps straight to the
+ -- counter), so the sign-off rides on this path
+ { "show_text", "_SafariZoneGateSafariZoneWorker1ReturnSafariBallsText" },
+ { "show_text", "_SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText" },
+ -- no value: set_field assigns nil, which is how save.safari is cleared
+ { "set_field", "safari" },
+ -- move_player runs through scriptMove, which skips onStepComplete, so
+ -- walking back down past (x,2) cannot re-fire the join trigger
+ { "move_player", "down", 3 },
+ { "jump", "end" },
+ { "label", "stay" },
+ { "show_text", "_SafariZoneGateSafariZoneWorker1GoodLuckText" },
+ { "warp", "SAFARI_ZONE_CENTER", dest.x, dest.y, "up" },
+ })
end,
}
diff --git a/data/scripts/seafoam.lua b/data/scripts/seafoam.lua
index dcd1afe4..4d0206cf 100644
--- a/data/scripts/seafoam.lua
+++ b/data/scripts/seafoam.lua
@@ -11,7 +11,7 @@
-- field.seafoam (SEAFOAM_ISLANDS_1F/B1F/B3F holes+holeDestination and
-- B3F's pluggedByHolesOn) plus the generic
-- OverworldState:boulderIntoHole in src/world/OverworldController.lua;
--- no per-map onEnter hook is needed here.
+-- no per-map onEnter hook is needed for the boulders.
local M = {}
@@ -23,4 +23,46 @@ M.VERMILION_GYM = {
end,
}
+-- The PLAYER falling down those same holes (#599). field.seafoam's holes
+-- carry only the BOULDER's object cell on the floor below (landsAt), not
+-- the player's landing, so the player's landing is spelled out here: it
+-- comes from data/maps/special_warps.asm DungeonWarpList/DungeonWarpData,
+-- which the importer does not extract. Same shape as MANSION_HOLES in
+-- data/scripts/story6.lua and VICTORY_ROAD_3F.onStep in
+-- data/scripts/story.lua; CAVERN $22 is a walkable tile, so the fall has
+-- to be an onStep, not a collision block.
+--
+-- Sources: scripts/SeafoamIslands1F.asm Seafoam1HolesCoords (17,6)/(24,6),
+-- B1F.asm Seafoam2HolesCoords (18,6)/(23,6), B2F.asm Seafoam3HolesCoords
+-- (19,6)/(22,6), B3F.asm Seafoam4HolesCoords (3,16)/(6,16). Each floor
+-- sets wDungeonWarpDestinationMap and calls IsPlayerOnDungeonWarp, and
+-- wCoordIndex picks that floor's DungeonWarpData row. Unconditional in
+-- the original: a plugged hole still drops the player. The B3F and B4F
+-- landings are water; setMap's CheckForceBikeOrSurf pass
+-- (OverworldState:checkForcedMovement) mounts SURF on arrival.
+local HOLE_FALLS = {
+ SEAFOAM_ISLANDS_1F = { { 17, 6, "SEAFOAM_ISLANDS_B1F", 18, 7 },
+ { 24, 6, "SEAFOAM_ISLANDS_B1F", 23, 7 } },
+ SEAFOAM_ISLANDS_B1F = { { 18, 6, "SEAFOAM_ISLANDS_B2F", 19, 7 },
+ { 23, 6, "SEAFOAM_ISLANDS_B2F", 22, 7 } },
+ SEAFOAM_ISLANDS_B2F = { { 19, 6, "SEAFOAM_ISLANDS_B3F", 18, 7 },
+ { 22, 6, "SEAFOAM_ISLANDS_B3F", 19, 7 } },
+ SEAFOAM_ISLANDS_B3F = { { 3, 16, "SEAFOAM_ISLANDS_B4F", 4, 14 },
+ { 6, 16, "SEAFOAM_ISLANDS_B4F", 5, 14 } },
+}
+
+for mapId, holes in pairs(HOLE_FALLS) do
+ M[mapId] = M[mapId] or {}
+ M[mapId].onStep = function(game, ow, x, y)
+ for _, h in ipairs(holes) do
+ if x == h[1] and y == h[2] then
+ require("src.core.Sound").play(game.data, "Faint_Fall")
+ ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
+ return true
+ end
+ end
+ return false
+ end
+end
+
return M
diff --git a/data/scripts/story.lua b/data/scripts/story.lua
index 84d17361..53be89c5 100644
--- a/data/scripts/story.lua
+++ b/data/scripts/story.lua
@@ -188,7 +188,12 @@ M.BILLS_HOUSE = {
end
if ow.player.facing == "down" then
-- the player is standing on his straight path: walk around
- -- (.PokemonWalkAroundPlayerMovement)
+ -- (.PokemonWalkAroundPlayerMovement). BillsHouseScript2 runs
+ -- BillsHousePikachuWatchPlayer first on this branch, so a
+ -- Pikachu that is still following steps clear of Bill's detour
+ -- and turns to watch the player (#455).
+ require("src.world.PikachuFollower")
+ .onBillWalksAroundPlayer(game, ow)
ow:scriptMove(npc, "right", 1, function()
ow:scriptMove(npc, "up", 2, function()
ow:scriptMove(npc, "left", 1, function()
@@ -600,12 +605,34 @@ local function snorlaxWake(mapId, objName, beatFlag, wokeUpText, calmedText)
}
end
+-- A beaten Snorlax is gone for good: Route12/Route16DefaultScript run
+-- HideObject in the same breath as the battle that sets
+-- EVENT_BEAT_ROUTEnn_SNORLAX, so "event set, object still on the map" is a
+-- state the asm cannot produce. Here it can (a mod's world:toggleObject, a
+-- save edited or migrated from a build older than the flag), and it is a
+-- dead end: ItemEffects' adjacentSleepingSnorlax refuses to wake a Snorlax
+-- whose beat flag is set (ItemUsePokeFlute's CheckEvent, engine/items/
+-- item_effects.asm), so the sleeper sits in the road forever and Cycling
+-- Road is unreachable (#585). Reconcile the toggle from the flag on every
+-- entry -- the mirror of SaveData.lua's toggle -> flag backfill, and the
+-- same repair shape the Silph Co. floors use below.
+local function hideBeatenSnorlax(mapId, objName, beatFlag)
+ return function(game, ow)
+ if not game.save.flags[beatFlag] then return end
+ local Commands = require("src.script.Commands")
+ Commands.hide_object({ game = game, save = game.save, overworld = ow },
+ mapId, objName)
+ end
+end
+
-- snorlaxWake is looked up by ItemEffects.lua/BagMenu.lua (via
-- data/scripts/init.lua's M.get) and run when the flute wakes Snorlax;
-- objName/beatFlag let ItemEffects find the NPC and check whether it's
-- already been beaten before allowing the wake.
M.ROUTE_12 = {
talk = { TEXT_ROUTE12_SNORLAX = { { "show_text", "_Route12SnorlaxText" } } },
+ onEnter = hideBeatenSnorlax("ROUTE_12", "ROUTE12_SNORLAX",
+ "EVENT_BEAT_ROUTE12_SNORLAX"),
snorlaxWake = {
objName = "ROUTE12_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE12_SNORLAX",
script = snorlaxWake("ROUTE_12", "ROUTE12_SNORLAX", "EVENT_BEAT_ROUTE12_SNORLAX",
@@ -614,6 +641,8 @@ M.ROUTE_12 = {
}
M.ROUTE_16 = {
talk = { TEXT_ROUTE16_SNORLAX = { { "show_text", "_Route16Text7" } } },
+ onEnter = hideBeatenSnorlax("ROUTE_16", "ROUTE16_SNORLAX",
+ "EVENT_BEAT_ROUTE16_SNORLAX"),
snorlaxWake = {
objName = "ROUTE16_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE16_SNORLAX",
script = snorlaxWake("ROUTE_16", "ROUTE16_SNORLAX", "EVENT_BEAT_ROUTE16_SNORLAX",
@@ -645,16 +674,18 @@ M.SAFARI_ZONE_SECRET_HOUSE = {
M.WARDENS_HOUSE = {
talk = {
TEXT_WARDENSHOUSE_WARDEN = {
+ -- Labelled rather than hand-numbered: the branches here have been
+ -- re-pointed twice now (#535, #645), and every insert used to mean
+ -- renumbering three jumps that had no way of announcing they were stale.
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_HM04" }, -- 2
-- #535: previously jumped to the same silent-end target as the
- -- give-then-thank fallthrough (row 13), so the warden said nothing
- -- on every visit after the trade. pokered's .got_item branch
- -- (scripts/WardensHouse.asm) instead prints HM04ExplanationText,
- -- so route here to the new row 17 that does the same.
- { "jump_if_true", 17 }, -- 3
+ -- give-then-thank fallthrough, so the warden said nothing on every
+ -- visit after the trade. pokered's .got_item branch
+ -- (scripts/WardensHouse.asm) instead prints HM04ExplanationText.
+ { "jump_if_true", "got_hm04" }, -- 3
{ "check_item", "GOLD_TEETH" }, -- 4
- { "jump_if_false", 15 }, -- 5
+ { "jump_if_false", "no_teeth" }, -- 5
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
{ "take_item", "GOLD_TEETH", 1 }, -- 7
{ "set_flag", "EVENT_GAVE_GOLD_TEETH" }, -- 8
@@ -663,15 +694,27 @@ M.WARDENS_HOUSE = {
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
- { "jump", 18 }, -- 13 (already got it this convo; jp .done)
- { "jump", 18 }, -- 14 (unused)
- { "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
- { "jump", 18 }, -- 16 (#535: skip the new explanation row below)
+ { "jump", "end" }, -- 13 (jp .done)
+
+ -- #645: WardensHouseWardenText prints Gibberish1, then YesNoChoice,
+ -- and the warden answers the same gibberish either way -- Gibberish2
+ -- on yes, Gibberish3 on no (scripts/WardensHouse.asm). The port
+ -- printed the question and walked off before the answer.
+ { "label", "no_teeth" }, -- 14
+ { "ask", "_WardensHouseWardenGibberish1Text" }, -- 15
+ { "jump_if_true", "gibberish_yes" }, -- 16
+ { "show_text", "_WardensHouseWardenGibberish3Text" }, -- 17
+ { "jump", "end" }, -- 18
+ { "label", "gibberish_yes" }, -- 19
+ { "show_text", "_WardensHouseWardenGibberish2Text" }, -- 20
+ { "jump", "end" }, -- 21
+
-- #535: pokered .got_item branch (scripts/WardensHouse.asm) --
-- printed on every subsequent talk once EVENT_GOT_HM04 is set.
-- Text is _WardensHouseWardenHM04ExplanationText (text/WardensHouse.asm):
-- HM04 teaches Strength, and hints at the Safari Zone secret house.
- { "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 17
+ { "label", "got_hm04" }, -- 22
+ { "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 23
},
},
}
@@ -899,6 +942,7 @@ M.VICTORY_ROAD_3F = {
-- fall is onStep, not a collision block.
onStep = function(game, ow, x, y)
if x == 23 and y == 15 then
+ require("src.core.Sound").play(game.data, "Faint_Fall")
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
return true
end
diff --git a/data/scripts/story6.lua b/data/scripts/story6.lua
index d847c09b..e28c1230 100644
--- a/data/scripts/story6.lua
+++ b/data/scripts/story6.lua
@@ -118,6 +118,7 @@ local MANSION_HOLES = {
M.POKEMON_MANSION_3F.onStep = function(game, ow, x, y)
for _, h in ipairs(MANSION_HOLES) do
if x == h[1] and y == h[2] then
+ require("src.core.Sound").play(game.data, "Faint_Fall")
ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
return true
end
diff --git a/data/scripts/yellow_viridian_old_man.lua b/data/scripts/yellow_viridian_old_man.lua
new file mode 100644
index 00000000..0bf2380d
--- /dev/null
+++ b/data/scripts/yellow_viridian_old_man.lua
@@ -0,0 +1,145 @@
+-- The Viridian City old-man catch tutorial, Yellow's way
+-- (pokeyellow scripts/ViridianCity.asm, scripts/ViridianCity_2.asm,
+-- scripts/OaksLab.asm). Registered on top of the shared tables by
+-- data/scripts/init.lua on a Yellow boot.
+--
+-- Yellow has TWO gambler objects where Red/Blue have one:
+-- * VIRIDIANCITY_OLD_MAN at (17,5) -- a Red/Blue leftover; its
+-- toggle stays OFF forever in Yellow, no script ever shows it.
+-- * VIRIDIANCITY_OLD_MAN2 at (18,9) -- replaces the sleeper the
+-- moment the Pokédex is given (OaksLabOakGivesPokedexScript:
+-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN_2).
+--
+-- This is the tutorial old man. The Red/Blue "Are you in a hurry?"
+-- yes/no script must NOT run against him: Yellow's
+-- _ViridianCityOldManHadMyCoffeeNowText is the apology speech ("I've had
+-- my coffee now ... I'll show you how to catch POKéMON as my apology"),
+-- and the shared story.lua TEXT_VIRIDIANCITY_OLD_MAN rows hang an
+-- invented yes/no over it -- YES printed the TimeIsMoney alias
+-- (_ViridianCityOldManLosingMyTouchText) and NO ran the demo, every
+-- talk, forever (#617).
+--
+-- The real flow (ViridianCityCheckWaitingOldMan + ViridianCityOldMan2Text
+-- + ViridianCityOldManInitialCatchTrainingScript + ...EndInitial... +
+-- ViridianCityPostInitialCatchTraining): stepping into (19,9) -- the gap
+-- east of the sleeper's cell -- faces the old man right and the player
+-- left, prints the apology, and without any choice runs the demo battle
+-- (BATTLE_TYPE_OLD_MAN, RATTATA lvl 5). After it, the same text pointer
+-- now prints _ViridianCityOldManLosingMyTouchText ("That didn't work!
+-- I must be losing my touch."), the old man walks off (down 6 with the
+-- player on (19,9), right 1 otherwise, Pikachu nudged out of the way
+-- first) and TOGGLE_OLD_MAN_2 hides. A direct talk does the same.
+
+local M = {}
+
+local OLD_MAN2 = "VIRIDIANCITY_OLD_MAN2"
+
+-- Capture the FUNCTION, not the table: attachBase stores the module
+-- table itself, so once this file's onStep is attached the table's slot
+-- points back here -- delegating through the table would self-recurse.
+-- story5's VIRIDIAN_CITY.onStep chains story.lua's sleeping-old-man
+-- gate and its own gym-lock step (same pattern as yellow_jessie_james).
+local baseViridianStep = require("data.scripts.story5").VIRIDIAN_CITY.onStep
+
+-- pokeyellow text/ViridianCity.asm, _ViridianCityOldManHadMyCoffeeNowText
+-- and _ViridianCityOldManLosingMyTouchText, spelled with the extractor's
+-- markers (line -> \n, cont -> \v, para -> \f)
+local function text(game)
+ return {
+ apology = game.data.text._ViridianCityOldManHadMyCoffeeNowText
+ or "Ahh, I've had my\ncoffee now and I\vfeel great!\fSure, you can go\n"
+ .. "through!\fI'm sorry I was\nso rude to you!\fI see you're using\n"
+ .. "a POKéDEX.\fI'll show you how\nto catch POKéMON\vas my apology.",
+ losingMyTouch = game.data.text._ViridianCityOldManLosingMyTouchText
+ or "That didn't work!\nI must be losing\vmy touch.\fI've run out of\n"
+ .. "POKé BALLs too.\fI have to get some\nat POKéMON MART.",
+ }
+end
+
+-- The row list for the initial-tutorial branch, keyed by where the
+-- player stands when the battle ends (ViridianCityPostInitialCatchTraining
+-- reads wXCoord: (19,9) walks the old man down the corridor, anywhere
+-- else walks him right 1 after moving the follower Pikachu aside).
+local function oldMan2Rows(game, ow, npc)
+ local rows = {
+ { "show_text", "_ViridianCityOldManHadMyCoffeeNowText" },
+ { "old_man_demo" },
+ { "set_flag", "EVENT_COMPLETED_CATCH_TRAINING" },
+ { "show_text", "_ViridianCityOldManLosingMyTouchText" },
+ }
+ if ow.player and ow.player.cellX == 19 then
+ rows[#rows + 1] =
+ { "walk_npc", npc.def.index,
+ { "down", "down", "down", "down", "down", "down" } }
+ else
+ -- ViridianCityMovePikachu (scripts/ViridianCity_2.asm): Pikachu
+ -- steps out of the old man's way before he turns right
+ local PikachuFollower = require("src.world.PikachuFollower")
+ local pika = ow and PikachuFollower.current(ow)
+ if pika then
+ rows[#rows + 1] = { "walk_npc", pika.def.index, { "right" } }
+ end
+ rows[#rows + 1] = { "walk_npc", npc.def.index, { "right" } }
+ end
+ rows[#rows + 1] =
+ { "hide_object", "VIRIDIAN_CITY", OLD_MAN2 }
+ return rows
+end
+
+-- The shared talk handler: TEXT_VIRIDIANCITY_OLD_MAN2's text_asm branch
+-- (ViridianCityOldMan2Text) on EVENT_COMPLETED_CATCH_TRAINING.
+local function oldMan2Talk(game, ow, npc, done)
+ if game.save.flags and game.save.flags.EVENT_COMPLETED_CATCH_TRAINING then
+ local TextBox = require("src.render.TextBox")
+ game.stack:push(TextBox.new(game, text(game).losingMyTouch, done))
+ return
+ end
+ ow.runner:run(oldMan2Rows(game, ow, npc), { npc = npc, onDone = done })
+end
+
+M.VIRIDIAN_CITY = {
+ talk = {
+ TEXT_VIRIDIANCITY_OLD_MAN2 = oldMan2Talk,
+ },
+
+ -- Re-apply the Pokédex swap for a save that already holds the flag but
+ -- was never standing here when it fired (converted .sav imports, same
+ -- shape as story.lua's VIRIDIAN_CITY.onEnter). Yellow shows OLD_MAN2,
+ -- not the Red/Blue OLD_MAN at (17,5), and also puts away a stray
+ -- OLD_MAN a save made by the pre-#617 build left standing.
+ onEnter = function(game, ow)
+ if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
+ return
+ end
+ local Commands = require("src.script.Commands")
+ local ctx = { save = game.save, game = game, overworld = ow }
+ Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY")
+ Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN")
+ Commands.show_object(ctx, "VIRIDIAN_CITY", OLD_MAN2)
+ end,
+
+ -- ViridianCityCheckWaitingOldMan: with the Pokédex held and the
+ -- tutorial undone, (19,9) -- the gap east of the old man, the same
+ -- cell the sleeper used to gate -- faces him right, turns the player
+ -- left and starts the OLD_MAN2 flow with no choice.
+ onStep = function(game, ow, x, y)
+ if baseViridianStep and baseViridianStep(game, ow, x, y) then
+ return true
+ end
+ local flags = game.save.flags
+ if not flags.EVENT_GOT_POKEDEX then return false end
+ if flags.EVENT_COMPLETED_CATCH_TRAINING then return false end
+ if x ~= 19 or y ~= 9 then return false end
+ local man
+ for _, npc in ipairs(ow.npcs) do
+ if npc.def and npc.def.name == OLD_MAN2 then man = npc break end
+ end
+ if not man then return false end
+ man.facing = "right"
+ ow.player.facing = "left"
+ oldMan2Talk(game, ow, man, nil)
+ return true
+ end,
+}
+
+return M
diff --git a/docs/new-features.md b/docs/new-features.md
index 70f7ff53..532cbf3a 100644
--- a/docs/new-features.md
+++ b/docs/new-features.md
@@ -209,10 +209,15 @@ duration:
closes, and apply again after. Fast-forward otherwise runs one peer's
queue faster than the peer it is locked to and drains a tournament shot
clock faster than the opponent racing it.
-- **Online play runs vanilla.** Picking ONLINE MATCH or TOURNAMENT with
- mods enabled offers to switch them all off and relaunch (mods merge at
- boot, so a restart is the only way). The restart is confirmed, not
- silent. They stay listed as disabled, ready to switch back on.
+- **Online play runs vanilla, except for your language.** Picking ONLINE
+ MATCH or TOURNAMENT with mods enabled offers to switch the gameplay ones
+ off and relaunch (mods merge at boot, so a restart is the only way). The
+ restart is confirmed, not silent. They stay listed as disabled, ready to
+ switch back on. A mod that declares itself a translation and provably
+ writes nothing but text stays on: the two games hash the same link
+ surface, so a Spanish install and an English one can battle and trade,
+ each reading the game in its own language and naming the other player's
+ party out of its own text.
- **Only a meaningful split ends a match.** The per-turn state signature
both peers exchange is split three ways: `actives` and `bench` carry
species, HP, status, stat stages, PP and the rest of the party, and a
@@ -314,12 +319,18 @@ window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
the overlay on and lets the mouse act as a finger (`=0` forces it off).
The launcher's **Touch Controls** button opens a drag editor: move each
-button freely, **Disable** to hide the overlay permanently (for
-controllers / emulation handhelds -- distinct from the temporary
-gamepad auto-hide), **Reset** for defaults, **Done** to save into
-`options.lua` as normalized window fractions so rotation keeps the
-relative placement. In-game, Options → **TOUCH PAD** toggles the same
-on/off flag without leaving a play session.
+button freely, resize the whole pad with **-/+** (60% to 160%), **Disable**
+to hide the overlay permanently (for controllers / emulation handhelds --
+distinct from the temporary gamepad auto-hide), **Reset** for defaults,
+**Done** to save into `options.lua` as normalized window fractions so a
+different screen keeps the relative placement.
+
+Portrait and landscape are edited and saved separately (#633): the editor
+follows whichever orientation is on screen, and **Reset** only clears that
+one, so a layout that works held upright does not have to double as the
+one used sideways. An `options.lua` from before this split keeps its single
+layout in both orientations until one of them is edited. In-game, Options →
+**TOUCH PAD** toggles the same on/off flag without leaving a play session.
## Translation support
@@ -382,7 +393,9 @@ semantics - so the two windows read as one app. Six tabs:
- **Items**: money, a searchable item picker (replacing the arrows that
cycled one id at a time through ~250 items), the configurable bag (20 slots
by default), PC storage
- with no slot cap, and the eight badges as toggle chips.
+ with no slot cap, and the eight badges as toggle chips. The picker, the bag
+ and PC storage all scroll under the mouse wheel, so the whole catalog is
+ reachable one-handed without typing a query.
- **Events**: flags, defeated trainers, taken items and per-map object
toggles, with a real filter field and a two-column paged grid.
- **Map**: any map rendered with the game's own renderer, warps followable,
@@ -460,3 +473,38 @@ two buttons. Holding a second key or pad button while the first is still
down backs out of the capture without touching a keyboard; Escape still
cancels too. SELECT clears one row back to its default, and START resets
every binding after a confirmation.
+
+Controllers a system has no mapping for (common on Linux handhelds and
+off-brand pads) report bare button numbers rather than names. Those are
+rebindable on the same screen and show up as JOY1, JOY2 and so on in the
+controller column. Recognized controllers are read only through their
+named buttons, so a rebind on those is never shadowed by the factory
+layout underneath it.
+
+## Mod profiles (#593)
+
+The mod manager's PROFILES tab holds named setups. A profile remembers which
+mods are on, every mod's own options, and which save slot each game version
+plays, so swapping profiles swaps the whole playthrough and not just the mod
+list. The setup that existed before profiles shipped becomes PROFILE 1 the
+first time the manager opens.
+
+EXPORT.. writes the selected profile to `profiles/.g1rmodlist` in the
+save directory; drop a `.g1rmodlist` someone shared into that folder and
+IMPORT.. adds it. Imported profiles never overwrite an existing one (a name
+clash gets a number). Mods the shared profile names but that are not installed
+are reported when the profile is applied; installing them is still a manual
+trip through the mods list or Find Mods.
+
+## Windows: no console windows on launcher actions
+
+Checking for updates, browsing a mod index, adding a mod repo, installing a
+mod and picking a ROM all run a host tool (curl, PowerShell) in a child
+process. On Windows those children used to each open their own console
+window, so a session could end up buried under half a dozen of them. The
+game now claims one console for itself at boot and hides it; the children
+inherit that invisible console and nothing pops up. Nothing else changes:
+file pickers are ordinary desktop dialogs and still appear normally, and a
+run started from a terminal (`lovec.exe`, what `scripts\run.ps1` prefers)
+keeps its terminal and its printed output. Set `POKEPORT_CONSOLE=1` to opt
+out.
diff --git a/docs/rfcs/0001-surfing-pikachu-sprite.md b/docs/rfcs/0001-surfing-pikachu-sprite.md
new file mode 100644
index 00000000..afa7d351
--- /dev/null
+++ b/docs/rfcs/0001-surfing-pikachu-sprite.md
@@ -0,0 +1,96 @@
+# RFC 0001 — Port Yellow's `IsSurfingPikachuInParty` surf sprite
+
+## Status
+
+Proposed. Engine: `Player.lua`, `FieldDefaults.lua`,
+`OverworldController.lua`, `RomExtractor.lua`, `PaletteFX.lua`. Tools:
+`build_rom_data.py`, `extract/sprites.py`, `make_rom_manifest.py`,
+`make_yellow_manifest.py`. Tests: `parity_surfing_pikachu_sprite.lua`,
+`mod_world_tests.lua`.
+
+**Regeneration required.** The manifest and sprite sheet update by
+re-running `make_yellow_manifest.py` against a `pret/pokeyellow`
+checkout, then re-importing the Yellow ROM.
+
+## Motivation
+
+Yellow's `IsSurfingPikachuInParty` + `LoadSurfingPlayerSpriteGraphics2`
+(`home/map_objects.asm`, `home/overworld.asm`) swap the player's
+overworld sheet to `SurfingPikachuSprite` (`gfx/sprites/
+surfing_pikachu.2bpp`, a 16×96 walk sheet — not the minigame sheets)
+when the party mon that knows SURF is a Pikachu. The recomp misses this
+in two places:
+
+1. **Extraction.** `SurfingPikachuSprite` is not in
+ `SpriteSheetPointerTable` — loaded by its own `ld de,` like
+ `RedBikeSprite`. The extractor never sees it, and the symbol is not
+ in the Yellow manifest.
+2. **Engine rule.** `field.playerSprites.surf` is one static
+ (`SPRITE_SEEL`), cached at boot. No seam for "swap when the SURF-mon
+ is a Pikachu."
+
+## The decision it extends
+
+No prior D-number. Extends the surf-field-move port in
+`docs/behavior-porting-notes.md` (the `IsSurfingAllowed` exact port)
+with the player-sprite swap vanilla runs alongside it.
+
+## The exact API delta
+
+Backward-compatible, additive-only.
+
+### `field.playerSprites.surfPikachu`
+
+New optional key alongside `walk`/`surf`/`bike`/`fly`, defaults to
+`SPRITE_SURFING_PIKACHU`. Guarded in `Player.new` so before extraction
+lands the ride keeps the Seel — no plain on-water Pikachu.
+
+### `Player.surfPikachuSprite`
+
+`Player.new` builds a second `SpriteRenderer` when the field resolves.
+`pose()` picks it when `surfing and surfingPikachu`.
+
+### `Player.surfingPikachu` (runtime)
+
+Runtime-only boolean (not persisted); re-derived so a party change
+between save and load is honored.
+
+### `OverworldState:syncSurfingPikachu()`
+
+Sets `player.surfingPikachu` from `partyKnows("SURF")`. Called at every
+surf-state toggle: trySurf, dismount, flyTo, beginTeleportOut,
+warpToHealPoint, forced-surf tile, setMap boot-restore.
+
+### Importer — `SPRITE_SURFING_PIKACHU`
+
+`make_yellow_manifest.py` adds `SurfingPikachuSprite` to
+`YELLOW_EXTRA_SYMBOLS`. `make_rom_manifest.py`'s `sprite_metadata()`
+gains a `surfPikachu` entry (guarded, so Red/Blue unchanged).
+`RomExtractor.extractSprites` + `build_rom_data.py` + `extract/sprites.py`
+each gain a parallel extract mirroring `RedBikeSprite`.
+
+### `PaletteFX.spriteObp`
+
+`SurfingPikachuSprite` joins `RedBikeSprite` in the no-bracket-index
+special case, wearing the player's OBP palette so it colors in GBC mode.
+
+## Migration note for existing mods
+
+**Nothing.** `surf` still defaults to `SPRITE_SEEL`; `surfPikachu`
+only resolves on a Yellow import after regeneration. No manifest or
+`mod.save` shape changes. An eligibility hook that swaps a rental
+SURF-mon still drives the sprite pick via `partyKnows`.
+
+## Parity tests
+
+- **No-mod** (`mod_world_tests.lua`): `surf == "SPRITE_SEEL"`,
+ `surfPikachu == "SPRITE_SURFING_PIKACHU"` seeded at boot. The 19229-check
+ `world & maps v2` suite stays green.
+- **Mod-API** (`parity_surfing_pikachu_sprite.lua`): `syncSurfingPikachu`
+ + `Player:pose` across four party shapes (12/12). The existing
+ `parity_cinnabar_east_surf.lua` (24/24) stays green.
+
+## Deprecation etiquette
+
+Nothing deprecated. Additive: a new `field.playerSprites` key, a new
+runtime flag, a new engine method, a new sprite id.
\ No newline at end of file
diff --git a/docs/timing-parity.md b/docs/timing-parity.md
new file mode 100644
index 00000000..c8bd0fb5
--- /dev/null
+++ b/docs/timing-parity.md
@@ -0,0 +1,382 @@
+# Timing parity with the Game Boy
+
+The port's clock is faithful: `src/core/FixedStep.lua` advances game logic in
+whole 1/60 s steps off wall-clock `dt`, the default speed multiplier is 1x
+(`src/core/GameSpeed.lua:22`), and audio runs on its own real-time accumulator
+so fast-forward cannot pitch it. Almost nothing in `src/` is seconds-based.
+
+What diverges is the **frame budget of composed sequences**. The original
+spends a large fraction of its running time inside `DelayFrames` calls that
+produce no visible change - the pause after a page break, the beat before a
+status move resolves, the drain of an HP bar one point at a time. Those are
+invisible in a screenshot and easy to drop when porting behavior rather than
+timing. Dropping them is why the port reads as faster and snappier than
+hardware even though every individual animation is correct.
+
+This document is the specification: what each sequence costs on hardware, and
+where that number comes from.
+
+## Method
+
+`tools/scan_pokered_delays.ps1` walks the disassembly and reports every
+frame-consuming wait with its enclosing routine label:
+
+| Kind | Meaning | Frames |
+| --- | --- | --- |
+| `DelayFrames` | `ld c, N` + `call DelayFrames` (`home/delay.asm:1`) | N |
+| `DelayFrame` | one vblank wait (`home/vblank.asm:92`) | 1 |
+| `Delay3` | `home/palettes.asm:14`, three frames for a full bg-map update | 3 |
+| `Fade` | the `GBFade*` helpers, expanded to their totals below | 24 or 32 |
+
+Current inventory against `pokered-master`: **450 sites** - 181 `DelayFrames`,
+164 `Delay3`, 65 `DelayFrame`, 31 `GBFade*`, and 9 `DelayFrames` calls whose
+count is computed at runtime.
+
+The four fades all live in `home/fade.asm` and are loops of
+`ld c, 8 / call DelayFrames`:
+
+| Routine | Iterations | Frames | Source |
+| --- | --- | --- | --- |
+| `GBFadeInFromBlack` | 4 | **32** | `home/fade.asm:21` |
+| `GBFadeOutToBlack` | 4 | **32** | `home/fade.asm:43` |
+| `GBFadeOutToWhite` | 3 | **24** | `home/fade.asm:26` |
+| `GBFadeInFromWhite` | 3 | **24** | `home/fade.asm:48` |
+
+## The metric
+
+For each catalog entry, `delta = |port - truth| / truth`; the entry passes at
+`delta <= 0.05`. The headline number is the **exposure-weighted** pass rate,
+weighting each sequence by how often it occurs in ordinary play - a 30-frame
+error on every page of dialogue matters more than a 30-frame error in the Hall
+of Fame. Weights are in the tier column: T1 sequences recur constantly, T2 are
+frequent, T3 are set pieces seen once or twice per playthrough.
+
+## Tier 1 - constant exposure
+
+These recur every few seconds of play and dominate perceived pacing. Both
+sides are verified.
+
+### Overworld and transitions
+
+| Sequence | Hardware | Source | Port | Port source | Delta |
+| --- | --- | --- | --- | --- | --- |
+| Overworld loop iteration | 2 frames (two `DelayFrame`) | `home/overworld.asm:41-44` | 1 step | `src/core/Game.lua:174` | see note |
+| Warp / door: fade out | **32** | `home/overworld.asm:703` -> `GBFadeOutToBlack` | 32 | `src/render/Transition.lua` | **fixed** |
+| Warp / door: fade in | **0** (map is drawn under blacked palettes, no fade) | `home/overworld.asm:690-703` | 0 | `src/render/Transition.lua` | **fixed** |
+| Return to overworld after a battle | **10** hold, then `GBFadeInFromWhite` **24** | `home/overworld.asm:351-352`, `:22`, `:749-753` | 10 + 24 | `Transition.battleReturn` | **fixed** |
+| Special warp entry (fly / teleport / dungeon) | `Delay3` + `GBFadeInFromWhite` = **27** | `engine/overworld/player_animations.asm:5-7` | - | - | unmeasured |
+| Dungeon-warp arrival hold | **50** | `engine/overworld/player_animations.asm:43` | - | - | unmeasured |
+| Player step (walk) | 16 | 8 loop iterations x 2 frames | 16 | `src/world/Player.lua:14` | **ok** |
+| Turn in place | 2 | one extra loop pass | 2 | `src/world/Player.lua:18` | **ok** |
+
+Note on the overworld loop: `OverworldLoop` calls `DelayFrame` and then falls
+through to `OverworldLoopLessDelay`, which calls it again - so a full pass
+costs 2 frames, and input is sampled every other frame. The port steps logic
+and samples input every frame. This does not change walking speed (the 2-frame
+loop moves 2 px, giving the same 16 frames per tile) but it does halve input
+latency versus hardware. Flagged rather than "wrong": matching it exactly would
+make the port feel less responsive than the original does on a modern display,
+and it is the one place where a deliberate divergence is defensible.
+
+### Text
+
+The typewriter cadence itself is already correct - 1/3/5 frames per character
+from `TextSpeedOptionData`, implemented at `src/render/TextBox.lua:267`. The
+gaps around it are missing.
+
+| Sequence | Hardware | Source | Port | Port source | Delta |
+| --- | --- | --- | --- | --- | --- |
+| `` line scroll (after the A press) | `ProtectedDelay3` 3 + 2x `ScrollTextUpOneLine` 5 = **13** | `home/text.asm:262-277`, `:283-307` | 13 | `src/render/TextBox.lua` | **fixed** |
+| `` paragraph break | `ProtectedDelay3` 3 + clear + **20** = **23** | `home/text.asm:230-243` | 23 | `src/render/TextBox.lua` | **fixed** |
+| Page break (`PageChar`) | 3 + **20** = **23** | `home/text.asm:245-260` | 23 | `src/render/TextBox.lua` | **fixed** |
+| `TextCommand_PAUSE` | **30** | `home/text.asm:500` | - | - | unmeasured |
+| `TextCommand_DOTS` | **10** per dot | `home/text.asm:576` | - | - | unmeasured |
+
+The three ProtectedDelay3 frames are a *pre*-input hold: the arrow is already
+up and the button is ignored, because `ManualTextScroll` only starts watching
+the joypad after the delay returns. Mashing A through a long conversation
+therefore cannot go faster than 3 frames per line on hardware, and now cannot
+here either. `PromptText` (`home/text.asm:209-217`) has the same shape, so a
+finished page holds three frames before it can be dismissed too.
+
+**Battle text is a second, separate engine.** `BattleState` types its own
+messages rather than going through `src/render/TextBox.lua`, so none of the
+fixes above reached it and it had drifted further than the overworld box:
+
+| Sequence | Hardware | Source | Was | Now |
+| --- | --- | --- | --- | --- |
+| Per character | 1 glyph per `wOptions & $f` frames (1/3/5, default **3**) | `home/print_text.asm:4-45` | 2 glyphs **per frame**, option ignored | 3 |
+| Per character, A or B held | **1** frame | `print_text.asm:27-36` | 2 glyphs per frame | 1 |
+| `` pre-input hold | **3** | `home/text.asm:263-267` | 0 | 3 |
+| `` scroll after the press | **10** | `home/text.asm:280-305` | 0 | 10 |
+| Finished page, pre-input hold | **3** | `home/text.asm:213-217` | 0 | 3 |
+
+At the default text speed the battle typewriter was running **six times**
+hardware speed, which is most of why battle text read as a blur, and it
+ignored the OPTION text-speed setting entirely.
+
+`ScrollTextUpOneLine` is `ld b, 5` of `DelayFrame` (`home/text.asm:301-305`)
+and its own comment notes it is "always called twice in a row", so a CONT
+scroll blocks for 10 frames. The port's `scrollPx` slide at
+`src/render/TextBox.lua:305-307` is a cosmetic 8 px at 2 px/frame running in
+`draw()`, not on the logic step, and it does not gate the typewriter.
+
+### Menus
+
+| Sequence | Hardware | Source | Port | Port source | Delta |
+| --- | --- | --- | --- | --- | --- |
+| Yes/no answer (either option) | **15** | `engine/menus/text_box.asm:322-323`, `:333-334` | 15 | `src/ui/ChoiceBox.lua` | **fixed** |
+| List menu open (bag, PC, party-as-list) | **10** | `home/list_menu.asm:55-56` | 0 | `src/ui/ListMenu.lua` | **-100%, open** |
+| List menu redraw per input | `Delay3` = **3** | `home/list_menu.asm:64` | 0 | - | **-100%, open** |
+| Field move from the party menu | `Delay3` = **3** | `engine/menus/start_sub_menus.asm:4,27,175-203` | 7 (white flash) | `src/render/Transition.lua:8` | see note |
+| Teleport from the party menu | **60** + `Delay3` | `engine/menus/start_sub_menus.asm:224-225` | - | - | unmeasured |
+
+The port's 7-frame `white_flash` models `GBPalWhiteOutWithDelay3` plus the
+screen-tile restore, which is a defensible reading of the same sequence; it is
+listed here to be reconciled against the exact path rather than treated as a
+bug.
+
+### Battle entry
+
+The wipe into a battle and the silhouette slide behind it. Numbers here come
+from **pokered-c** (`C:\Users\Anthony\pokered`), whose `battle_transition.c`
+derives each wipe from `battle_transitions.asm` and then corrects it against a
+live side-by-side with the ROM. Where that project's measured value and a
+naive reading of the asm disagree, the measured value wins - see the inward
+spiral below.
+
+| Sequence | Hardware | Was | Now |
+| --- | --- | --- | --- |
+| DoubleCircle wipe (wild, weak) | 10 x 3 = **30** | 40 | 30 |
+| Circle wipe (wild, strong) | 20 x 3 = **60** | 40 | 60 |
+| Spiral outward (trainer, strong) | 360 fills / 3 per frame = **120** | 40 | 120 |
+| Spiral inward (trainer, weak) | 7 tiles per `Delay3` = **~150** | 40 | 156 |
+| HStripes (dungeon wild, weak) | 20 x 3 = **60** | 24 | 60 |
+| VStripes (dungeon wild, strong) | 18 x 3 = **54** | 24 | 54 |
+| Shrink (dungeon trainer, weak) | 9 x 6 = **54** | 24 | 54 |
+| Split (dungeon trainer, strong) | 9 x 6 = **54** | 24 | 54 |
+| Flash before the circle wipes | 12 x 2 x 3 = **72** | 72 | 72 |
+| Black hold before the battle draws | not stated by pokered; ~30 floor, calibrated **60** | 30 | 60 |
+| Silhouette slide in | 144 px at 2 px/frame = **72** | 40 (160 px at 4 px/frame) | 72 |
+| Trainer intro, before balls + text | `WaitForSoundToFinish` + `DelayFrames 20` | 0 | sfx wait + 20 |
+
+The trainer intro's sound is `SFX_Silph_Scope`, extracted here as
+`Trainer_Appeared` (`tools/rom_manifest.json` `audio.sfxHeaders`, bank 8 /
+`$42bb`). It was being extracted and never played by anything. It now plays
+into a clear window: `PrintBeginningBattleText .trainerBattle` does
+`PlaySound` then `WaitForSoundToFinish`, which **blocks**, and only then
+`DelayFrames 20` before `DrawAllPokeballs` and the text. `BattleState`'s
+message queue grew a `waitSound` row for that, since `WaitForSoundToFinish`
+waits on the sound actually stopping rather than on a fixed frame count.
+
+**Scripted battles were skipping the transition entirely.** `BattleTransition`
+runs from `DoBattleTransitionAndInitBattleVariables`, which both
+`InitBattleCommon` (`core.asm:6680`, trainers) and `InitWildBattle` (`:6699`)
+call unconditionally - every battle on hardware enters through a wipe. In the
+port only `OverworldState:pushBattle` built one, and `Commands.start_battle`
+pushed the `BattleState` straight onto the stack. The trainer-*sight* path
+went through `pushBattle`, but every **script-driven** battle did not: gym
+leaders, the rival, Giovanni, and every scripted wild encounter cut straight
+to the battle screen with no transition at all. The catch tutorial
+(`old_man_demo`) had the same gap; `InitWildBattle` has no
+`BATTLE_TYPE_OLD_MAN` special case, so it gets a wipe too.
+
+**Beyond 160x144.** Both halves of the transition used to stop at the classic
+letterbox. The flash filled the 160x144 UI canvas, and the wipe handed the
+surrounding window a generic centre-out square cascade
+(`Renderer:drawBattleCascade`) regardless of which of the eight styles was
+running - so at any zoom a spiral read as "a spiral in a box, with something
+else happening around it".
+
+- The flash is a palette write (`rBGP`), and a palette register tints every
+ pixel the LCD shows; there is no "outside the screen" for it to miss. It is
+ now published to the renderer as a screen-space veil and painted over the
+ finished composite, so it covers the whole surface at any zoom.
+- The spiral and circle walks are now generated for whatever grid the window
+ works out to (`BattleTransition.gridOrder`), and the area outside the
+ letterbox is filled in that order instead of the square cascade. The
+ authentic 20x18 builders still own the letterbox itself, overrun and all,
+ so nothing changes at 1x. The generic builders are deliberately *not* the
+ ROM's walk - out there the hardware has no behaviour to be faithful to,
+ only a shape to continue.
+- `shrink`, `split` and the two stripe styles are plain geometry rather than
+ a tile order, so they keep the cascade for now. Extending them is
+ rectangles, not a walk, and has not been done.
+
+Note that the **flash is wild-only**: `BattleTransition_FlashScreen` is called
+from `BattleTransition_Circle` (`:585`) and `BattleTransition_DoubleCircle`
+(`:628`) and nowhere else. A trainer battle's transition is the spiral -
+inward against a weaker foe, outward against a stronger one
+(`wBattleTransitionSpiralDirection`, `:119-126`) - with no flash in front of
+it. The port's `flash` mapping was already correct; the transition simply
+never ran for those battles.
+
+Two of these deserve their reasoning recorded:
+
+**The inward spiral** writes one tile per iteration and calls
+`BattleTransition_TransferDelay3` every seventh tile. That helper is not a
+one-frame transfer - it is `ld a,1 / ldh [hAutoBGTransferEnabled] / call
+Delay3 / xor a / ldh [...]` (`battle_transitions.asm:619`), so the cadence is
+7 tiles per **three** frames. Reading it as one frame runs the whole wipe 3x
+too fast, ~46 frames against the ROM's ~150. pokered-c hit that exact bug and
+caught it on a live comparison.
+
+**The black hold** is not a number pokered states anywhere; it is incidental
+load cost that a modern port does not pay. The derivable floor is ~13 frames
+(`LoadHpBarAndStatusTilePatterns` 4, `LoadHudTilePatterns` 2, `ClearScreen`'s
+`Delay3` 3, the `DisableLCD` LY wait 1, `Delay3` after `EnableLCD` 3), but the
+two sprite decompressors (`UncompressSpriteFromDE` for the 7x7 front pic and
+`LoadPlayerBackPic`'s uncompress + `ScaleSpriteByTwo`) are bit-level RLE/delta
+decoders that cannot be cycle-counted from the asm at all. So the derivation
+bottoms out around 25-30 with an unbounded remainder. pokered-c set 60 by ear
+against the real ROM and marked it ~95% right rather than frame-matched. The
+credible range is 30-60; it should not be "corrected" down toward the floor on
+the strength of the derivation, because the omitted decompressors are exactly
+the unbounded part. Pinning it exactly wants a frame-by-frame capture.
+
+### Battle turns
+
+| Sequence | Hardware | Source | Port | Port source | Delta |
+| --- | --- | --- | --- | --- | --- |
+| Player HP bar drain of D HP over P pixels | **D + 2P + 6** | `engine/gfx/hp_bar.asm:81-135`, `:140-159`, `:234` | D + 2P + 6 | `src/battle/BattleState.lua` `stepHPDrain` | **fixed** |
+| Enemy HP bar drain over P pixels | **2P + 5** (no per-HP frame) | same, gated at `:207-209` | 2P + 5 | same | **fixed** |
+| Status move / missed move beat | **30** | `engine/battle/core.asm:3145,3158,3185-3186`; enemy `:5588` | 30 | `EffectRegistry` `missBeat`, `BattleState:performMove` | **fixed** |
+| Applying anim type 1, vertical shake b=8 | **48** | `animations.asm:500-503` | 48 | `BattleState:applyHitFx` | ok |
+| Applying anim type 2, fast horizontal b=8 | **72** | `animations.asm:505-508` | 72 | same | ok |
+| Applying anim type 3, slow horizontal 6,2 | **48** | `animations.asm:510-512,526-549` | 48 | same | ok |
+| Applying anim type 4, `AnimationBlinkMon` | **60** | `animations.asm:514-516`, `:1360-1376` | 60 | same | **fixed** |
+| Applying anim type 5, fast horizontal b=2 | **18** | `animations.asm:518-521` | 18 | same | ok |
+| Applying anim type 6, slow horizontal 3,2 | **24** | `animations.asm:523-525` | 24 | same | ok |
+| Faint slide-down | **14** (`PIC_HEIGHT` x `DelayFrames 2`) | `engine/battle/core.asm:1186-1222` | 14 | `BattleState:onFaint` | **fixed** |
+| Before every move animation | `Delay3` = **3** | `engine/battle/core.asm:6635-6640` | 3 | `BattleState:updateQueue` | **fixed** |
+| Battle start, after enemy send-out | **40** | `engine/battle/core.asm:152-156` | 40 | `BattleState:enter` | **fixed** |
+| Poison / burn / leech-seed tick | **20** | `engine/battle/core.asm:529-530` | 20 | `src/battle/BattleState.lua:1817` | **ok** |
+| Switch player mon | **50** | `engine/battle/core.asm:2421-2422` | 50 | `src/battle/BattleState.lua:1666-1668` | **ok** |
+| Post-hit hold (crit text or not) | **20** | `engine/battle/core.asm:3798-3814` | 20 | `EffectRegistry.runDamaging` | **fixed** |
+| Fainted mon slide-down | **2** per row | `engine/battle/core.asm:1216-1217` | - | - | unmeasured |
+| Trainer pic slide off | **2** per column | `engine/battle/core.asm:1267-1268` | - | - | unmeasured |
+| Trainer battle victory | **40** | `engine/battle/core.asm:940-941` | - | - | unmeasured |
+| Player blackout | **40** | `engine/battle/core.asm:1143-1144` | - | - | unmeasured |
+| No moves left (Struggle) | **60** | `engine/battle/core.asm:2753-2754` | - | - | unmeasured |
+| Send-out animation | `Delay3` + **4** + **5** | `engine/battle/core.asm:6814-6830` | - | - | unmeasured |
+
+The HP bar was the single largest battle divergence. `UpdateHPBar` steps **one
+HP point per loop iteration**; each iteration pays 1 frame in
+`UpdateHPBar_PrintHPNumber` whenever `wHPBarType != 0` - the player's own HUD
+and the party menu, but not the enemy HUD - plus 2 frames for each pixel the
+bar actually moves. The tail (`.animateHPBarDone`, `:132-135`) prints the
+number once more, animates one last pixel and falls into `Delay3`, so it costs
+6 frames player-side and 5 enemy-side.
+
+A 150 HP mon losing everything therefore costs 150 + 96 + 6 = **252 frames
+(4.2 s)** on the player's HUD, against 96 + 5 = 101 on the enemy's. The port
+used a flat `maxHP/96` - the enemy-side rate applied to both sides - and ran
+that same drain in 96 frames (1.6 s).
+
+## Tier 2 - frequent
+
+Status-effect failures (`engine/battle/effects.asm:161,1162,1205`) hold **50**
+frames each. `SwitchAndTeleportEffect` uses 50 and 20
+(`:834-901`). Evolution holds **50** then **40**
+(`engine/pokemon/evos_moves.asm:123,155`). The healing machine
+(`engine/overworld/healing_machine.asm`), item effects
+(`engine/items/item_effects.asm`, 259 frames across 8 sites), and the fishing
+animation (**10** then **100**, `engine/overworld/player_animations.asm:380,399`)
+are all in this tier.
+
+## Tier 3 - set pieces
+
+Highest total budgets in the scan, all seen rarely:
+`engine/movie/hall_of_fame.asm` (529 frames / 7 sites),
+`engine/link/cable_club.asm` (507 / 12), `engine/movie/credits.asm` (502 / 7),
+`engine/movie/trade.asm` (494 / 19), `engine/movie/intro.asm` (317 / 7),
+`engine/menus/main_menu.asm` (271 / 15), `engine/menus/save.asm` (250 / 3),
+`engine/battle/end_of_battle.asm` (200 / 1, the link-battle win/lose string).
+Several of these already have faithful implementations - see
+`src/ui/IntroMovie.lua`, `src/ui/Credits.lua`, `src/ui/HallOfFame.lua`, whose
+constants cite their asm sources directly.
+
+## Status
+
+Closed, and locked by `tests/engine/timing_parity.lua`:
+
+1. **Text page and CONT breaks** - were 0 frames where hardware spends 13-23,
+ on every page of every dialogue in the game. Now exact, including the
+ three-frame pre-input hold that swallows a mashed A.
+2. **Player HP bar drain** - was ~2.5x too fast on a typical mon. Now steps
+ one HP point at a time at the hardware rate, with the enemy HUD correctly
+ cheaper than the player's.
+3. **Warp fade** - was a symmetric 12/12; now 32 out and no fade in, which is
+ both the right duration and the right shape.
+4. **Yes/no answer** - was 0 frames where hardware spends 15, with the cursor
+ snapping to NO on B for the duration as `.choseSecondMenuItem` does.
+
+5. **Battle entry** - every wipe ran at a flat 40/24 against budgets of 30-156,
+ the silhouette slide was 40 frames against 72, the black hold was half
+ what pokered-c calibrated, and the trainer intro's 20-frame gap before the
+ balls and text was missing entirely. This was the single most compressed
+ stretch in the game.
+
+6. **Battle turns** - the type-4 blink ran at 20 frames against 60. That is
+ the applying animation for every plain damaging move the player uses, so
+ it was the single most-repeated timing error in the game. The 30-frame
+ beat that hardware spends on every status move and every miss was missing
+ entirely. The faint slide, unusually, ran *slower* than hardware (30
+ against 14).
+
+Two things worth recording about that last batch, because both contradict a
+plausible reading:
+
+- **`PrintCriticalOHKOText`'s 20-frame hold is not conditional.** The
+ "no critical hit" early-out at `core.asm:3799` jumps to `.done`, and
+ `.done` *is* the `ld c, 20 / jp DelayFrames`. Every landed hit pays it,
+ which is where the beat before "It's super effective!" comes from.
+- **`StartBattle`'s 40-frame hold is not conditional either.** The `call nz`
+ at `core.asm:154` gates only `EnemySendOutFirstMon`; the `DelayFrames 40`
+ under it runs for wild battles too, and it lands *between* the enemy's
+ send-out and `.playerSendOutFirstMon` (`:166`) rather than at the end of
+ the intro.
+
+Trainer victory (24-frame scroll-in + 40-frame hold) and the ball shake
+(`SFX_TINK` + `DelayFrames 40` per rock) were already correct -
+`BattleState.lua`'s `wait = 64` and `AnimPlayer.lua`'s `emit(40)`. Note that
+pokered-c's `BUI_TRAINER_VICTORY_SLIDE` comment calls the scroll-in 14
+frames; `_ScrollTrainerPicAfterBattle` is 6 loop passes of `DelayFrames 4`,
+so 24 is right and this port already had it.
+
+**Leaving a battle was a cut, not a fade.** `EnterMap` checks
+`BIT_BATTLE_OVER_OR_BLACKOUT` and calls `MapEntryAfterBattle`
+(`home/overworld.asm:22`), which is `GBFadeInFromWhite` - so the map fades up
+from white over 24 frames, behind the 10-frame hold at `:351-352`. The port
+popped the battle and the overworld was simply there. `Transition.battleReturn`
+supplies both halves, and steps the veil in three palette stages of 8 frames
+rather than tweening it, because `GBFadeIncCommon` writes a palette and holds
+it with `ld c, 8 / call DelayFrames` (`home/fade.asm:30-41`) three times over.
+
+It is wrapped around `battle.onFinish` in `OverworldState:pushBattle` - the
+one funnel every battle goes through - rather than living in `afterBattle`.
+That placement matters: a script-driven **win** defers `afterBattle` into
+`ctx.afterScript` so an evolution screen cannot be buried under the trainer's
+follow-up text (`Commands.start_battle`), and a fade inside `afterBattle`
+inherited that deferral. On a rival battle it fired after the post-battle
+dialogue *and* the rival's walk-off, rather than when the battle ended.
+
+The rest of `onFinish` runs as the fade's `onDone`, which is also the hardware
+order: `MapEntryAfterBattle` fades the map back in and only then does the map
+script run. The overworld is frozen meanwhile - `StateStack` updates the top
+state only - so nothing moves under the white.
+
+Hardware skips the fade on a dark map (`wMapPalOffset` nonzero takes the
+`LoadGBPal` branch at `:754`). This port has no `wMapPalOffset` equivalent -
+no map needs FLASH to be lit - so that branch is unreachable here;
+`battleReturn` accepts `opts.instant` for it if that ever changes.
+
+Still open, hardware number confirmed but not yet wired:
+
+- List menu open (10) and per-input redraw (`Delay3`).
+- Every battle-table row marked "unmeasured" above - the status/miss beat (30)
+ is the highest-exposure of them, since it is paid on every status move and
+ every miss.
+
+Entries marked "unmeasured" have confirmed hardware numbers but the port side
+has not been traced; they are remaining work, not known-good.
diff --git a/main.lua b/main.lua
index 9c79e914..9d6f4341 100644
--- a/main.lua
+++ b/main.lua
@@ -204,6 +204,11 @@ local function bootGame(version)
end
function love.load(args)
+ -- Before anything can shell out (update check, mod index, ROM picker),
+ -- claim one hidden console on Windows so those children inherit it instead
+ -- of each flashing their own cmd.exe window (#606). No-op elsewhere.
+ require("src.core.HostShell").hideHostConsole()
+
-- Self-updater boot shell: a fused build may mount and chainload a newer
-- downloaded payload here. True means it took over, so we must stop. A
-- dev / source checkout no-ops (see src/update/Boot.lua).
diff --git a/mobile/ANDROID.md b/mobile/ANDROID.md
index b0bc4479..6683efde 100644
--- a/mobile/ANDROID.md
+++ b/mobile/ANDROID.md
@@ -73,6 +73,21 @@ of the build script's permission trim). The first
read runs immediately (`onRequestPermissionsResult`,
`STEP_PERMISSION_REQUEST_CODE`).
+### Network transport (mod index / mod updates)
+
+`love.system.httpDownload(url, absPath [, userAgent [, accept]])` ->
+`GameActivity.httpDownload` (same JNI route as the picker and the step
+bridge: `common/android.cpp` -> `modules/system/System.cpp` ->
+`wrap_System.cpp`). Android ships no `curl`, which is what the desktop
+builds fetch the mod index, mod release lists and mod zips with, so the
+"Find mods" tab used to fail with "curl is not available on this
+platform" (#597). The Java side is a blocking `HttpsURLConnection` GET
+(https only, redirects followed by hand, body renamed into place only
+once complete) and runs on LOVE's Lua thread, never the UI thread.
+`src/core/HostShell.lua` picks the transport: curl when present,
+otherwise this bridge; an APK older than the bridge simply reports no
+transport, exactly as a missing curl does.
+
### SDK / NDK
love-android 11.5a expects:
@@ -107,7 +122,7 @@ scripts, tests, and mobile build sources are excluded.
| `app.name` | Pokemon Red |
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
-| Permissions | INTERNET / RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH kept |
+| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept |
## Releases
diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp
index f295e527..d7b9230f 100644
--- a/mobile/android/love/src/jni/love/src/common/android.cpp
+++ b/mobile/android/love/src/jni/love/src/common/android.cpp
@@ -253,6 +253,41 @@ bool restartApp()
return result;
}
+bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept)
+{
+ if (url == nullptr || destPath == nullptr)
+ return false;
+
+ JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
+ jclass activity = env->FindClass("org/love2d/android/GameActivity");
+
+ // Old APK / new liblove skew: report "no transport" the same way a
+ // missing curl does, instead of aborting on a missing method (#597).
+ jmethodID method = env->GetStaticMethodID(activity, "httpDownload",
+ "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Z");
+ if (method == nullptr)
+ {
+ env->ExceptionClear();
+ env->DeleteLocalRef(activity);
+ return false;
+ }
+
+ jstring jurl = env->NewStringUTF(url);
+ jstring jdest = env->NewStringUTF(destPath);
+ jstring jua = env->NewStringUTF(userAgent != nullptr ? userAgent : "gen1recomp");
+ jstring jaccept = accept != nullptr ? env->NewStringUTF(accept) : nullptr;
+
+ jboolean result = env->CallStaticBooleanMethod(activity, method, jurl, jdest, jua, jaccept);
+
+ env->DeleteLocalRef(jurl);
+ env->DeleteLocalRef(jdest);
+ env->DeleteLocalRef(jua);
+ if (jaccept != nullptr)
+ env->DeleteLocalRef(jaccept);
+ env->DeleteLocalRef(activity);
+ return result;
+}
+
/*
* Helper functions for the filesystem module
*/
diff --git a/mobile/android/love/src/jni/love/src/common/android.h b/mobile/android/love/src/jni/love/src/common/android.h
index 3f07e496..23bb905f 100644
--- a/mobile/android/love/src/jni/love/src/common/android.h
+++ b/mobile/android/love/src/jni/love/src/common/android.h
@@ -90,6 +90,14 @@ bool syncHealthSteps();
**/
bool restartApp();
+/**
+ * Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has
+ * no curl binary, so this is the transport src/core/HostShell.lua uses there
+ * for the mod index and mod updates (#597). userAgent / accept may be null.
+ * Returns whether a complete file was written.
+ **/
+bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept);
+
/*
* Helper functions for the filesystem module
*/
diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp
index 0bbf214b..8dd9e8de 100644
--- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp
+++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp
@@ -230,6 +230,20 @@ bool System::restartApp() const
#endif
}
+bool System::httpDownload(const char *url, const char *destPath,
+ const char *userAgent, const char *accept) const
+{
+#ifdef LOVE_ANDROID
+ return love::android::httpDownload(url, destPath, userAgent, accept);
+#else
+ LOVE_UNUSED(url);
+ LOVE_UNUSED(destPath);
+ LOVE_UNUSED(userAgent);
+ LOVE_UNUSED(accept);
+ return false;
+#endif
+}
+
bool System::hasBackgroundMusic() const
{
#if defined(LOVE_ANDROID)
diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h
index c0fc76b4..be1ea307 100644
--- a/mobile/android/love/src/jni/love/src/modules/system/System.h
+++ b/mobile/android/love/src/jni/love/src/modules/system/System.h
@@ -141,6 +141,14 @@ public:
**/
virtual bool restartApp() const;
+ /**
+ * Blocking HTTPS GET into an absolute host path (Android only; false
+ * elsewhere). Android has no curl, which is what every other platform
+ * fetches the mod index with (#597).
+ **/
+ virtual bool httpDownload(const char *url, const char *destPath,
+ const char *userAgent = nullptr, const char *accept = nullptr) const;
+
/**
* Gets if the user is playing music on background.
* Throws an exception on unsupported platforms.
diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp
index 8c4fb9cd..1b94ef7c 100644
--- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp
+++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp
@@ -123,6 +123,16 @@ int w_restartApp(lua_State *L)
return 1;
}
+int w_httpDownload(lua_State *L)
+{
+ const char *url = luaL_checkstring(L, 1);
+ const char *dest = luaL_checkstring(L, 2);
+ const char *ua = luaL_optstring(L, 3, nullptr);
+ const char *accept = luaL_optstring(L, 4, nullptr);
+ luax_pushboolean(L, instance()->httpDownload(url, dest, ua, accept));
+ return 1;
+}
+
int w_hasBackgroundMusic(lua_State *L)
{
lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -142,6 +152,7 @@ static const luaL_Reg functions[] =
{ "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp },
+ { "httpDownload", w_httpDownload },
{ "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 }
};
diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java
index 5c0380b5..04c8a827 100644
--- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java
+++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java
@@ -31,6 +31,8 @@ import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -565,6 +567,73 @@ public class GameActivity extends SDLActivity {
return true; // unreachable, but keeps the JNI signature honest
}
+ /**
+ * Blocking HTTPS GET into destPath, exposed as love.system.httpDownload
+ * and used by src/core/HostShell.lua. Android ships no curl binary, so
+ * every remote fetch the desktop builds do with curl (mod index feeds,
+ * mod release lists, mod zips, thumbnails) comes through here (#597).
+ * Runs on LOVE's Lua thread, never the UI thread, so the platform's
+ * network-on-main-thread rule is not in play and a blocking call matches
+ * the curl semantics the Lua callers already expect.
+ *
+ * Redirects are followed by hand because HttpURLConnection silently drops
+ * a redirect that changes protocol, and only https is accepted: the feeds
+ * live on GitHub Pages / raw and a downgrade to http must fail, not fetch.
+ * The body lands in a .part file and is renamed only once complete, so a
+ * dropped connection can never leave a half file the caller trusts.
+ */
+ @Keep
+ public static boolean httpDownload(String url, String destPath, String userAgent, String accept) {
+ if (url == null || destPath == null) return false;
+ HttpURLConnection conn = null;
+ File tmp = new File(destPath + ".part");
+ try {
+ String current = url;
+ for (int hop = 0; hop < 5; hop++) {
+ URL parsed = new URL(current);
+ if (!"https".equalsIgnoreCase(parsed.getProtocol())) return false;
+ conn = (HttpURLConnection) parsed.openConnection();
+ conn.setInstanceFollowRedirects(false);
+ conn.setConnectTimeout(15000);
+ conn.setReadTimeout(60000);
+ conn.setRequestProperty("User-Agent",
+ userAgent == null ? "gen1recomp" : userAgent);
+ if (accept != null) conn.setRequestProperty("Accept", accept);
+ int code = conn.getResponseCode();
+ if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) {
+ String next = conn.getHeaderField("Location");
+ conn.disconnect();
+ conn = null;
+ if (next == null) return false;
+ current = new URL(parsed, next).toString();
+ continue;
+ }
+ if (code < 200 || code > 299) return false;
+ InputStream in = new BufferedInputStream(conn.getInputStream());
+ OutputStream out = new BufferedOutputStream(new FileOutputStream(tmp));
+ try {
+ byte[] buf = new byte[16384];
+ int n;
+ while ((n = in.read(buf)) > 0) out.write(buf, 0, n);
+ } finally {
+ try { out.close(); } catch (IOException ignored) {}
+ try { in.close(); } catch (IOException ignored) {}
+ }
+ File dest = new File(destPath);
+ dest.delete();
+ if (!tmp.renameTo(dest)) return false;
+ return dest.length() > 0;
+ }
+ return false;
+ } catch (Exception e) {
+ Log.d("GameActivity", "httpDownload failed: " + e.getMessage());
+ return false;
+ } finally {
+ if (conn != null) conn.disconnect();
+ if (tmp.exists()) tmp.delete();
+ }
+ }
+
/**
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive /
diff --git a/mobile/ios/app-repo.json b/mobile/ios/app-repo.json
new file mode 100644
index 00000000..ac4e8828
--- /dev/null
+++ b/mobile/ios/app-repo.json
@@ -0,0 +1,17 @@
+{
+ "name": "gen1recomp App Repo",
+ "identifier": "com.theboisclub.gen1recomp.repo",
+ "iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png",
+ "apps": [
+ {
+ "name": "gen1recomp",
+ "bundleIdentifier": "com.theboisclub.gen1recomp",
+ "developerName": "bryanthaboi",
+ "iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png",
+ "localizedDescription": "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke",
+ "tintColor": "3b5ca8",
+ "category": "games",
+ "versions": []
+ }
+ ]
+}
diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh
index 5e8adc9d..7d5f1020 100755
--- a/scripts/build_ios.sh
+++ b/scripts/build_ios.sh
@@ -11,7 +11,8 @@
# --install after a --device build, install the app onto the
# first connected iPhone/iPad (unlock it first)
# --release Release configuration
-# --version X.Y.Z stamp MARKETING_VERSION / CURRENT_PROJECT_VERSION
+# --version X.Y.Z stamp the engine version into game.love plus
+# MARKETING_VERSION / CURRENT_PROJECT_VERSION
# --fetch Fetch LÖVE 12.0 sources and Apple dependencies into mobile/ios/love-src/
# --package-only Zip game.love + apply plist overlay; skip xcodebuild
#
@@ -341,6 +342,25 @@ pack_game_love() {
|| fail "game.love is missing $required"
done
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
+
+ if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
+ say "stamping engine version $VERSION into game.love"
+ local stamp_dir
+ stamp_dir="$(mktemp -d)"
+ mkdir -p "$stamp_dir/src/core"
+ sed -E "s/(engine[[:space:]]*=[[:space:]]*\")([^\"]*)(\")/\1$VERSION\3/" \
+ "$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
+ (cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
+ local version_re
+ version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
+ unzip -p "$LOVE_FILE" src/core/Version.lua \
+ | grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
+ || fail "version stamp failed: game.love does not report engine $VERSION"
+ rm -rf "$stamp_dir"
+ say "stamped engine version: $VERSION"
+ else
+ say "no X.Y.Z --version, shipping default engine (no stamp)"
+ fi
}
# Ensure game.love is in the love-ios Copy Bundle Resources phase (idempotent).
@@ -433,6 +453,86 @@ print("patched project.pbxproj")
PY
}
+suppress_love_dependency_warnings() {
+ local liblove_pbx="$XCODE_DIR/liblove.xcodeproj/project.pbxproj"
+ local love_pbx="$XCODE_DIR/love.xcodeproj/project.pbxproj"
+ [ -f "$liblove_pbx" ] || fail "missing $liblove_pbx"
+ [ -f "$love_pbx" ] || fail "missing $love_pbx"
+
+ python3 - "$liblove_pbx" "$love_pbx" <<'PY'
+import pathlib
+import sys
+
+def patch_configs(path, config_ids, settings):
+ text = path.read_text()
+ for config_id in config_ids:
+ marker = f"\t\t{config_id}"
+ start = text.find(marker)
+ if start < 0:
+ raise SystemExit(f"missing configuration {config_id}")
+ settings_start = text.find("\t\t\tbuildSettings = {\n", start)
+ block_end = text.find("\n\t\t};", settings_start)
+ if settings_start < 0 or block_end < 0:
+ raise SystemExit(f"invalid configuration {config_id}")
+ block = text[settings_start:block_end]
+ lines = block.splitlines(keepends=True)
+ for setting in settings:
+ key = setting.split(" = ", 1)[0].strip()
+ prefix = f"{key} ="
+ replaced = False
+ normalized = []
+ for line in lines:
+ if line.startswith(f"\t\t\t\t{prefix}"):
+ if not replaced:
+ normalized.append(setting)
+ replaced = True
+ else:
+ normalized.append(line)
+ if not replaced:
+ normalized.insert(1, setting)
+ lines = normalized
+ normalized_block = "".join(lines)
+ if normalized_block != block:
+ text = text[:settings_start] + normalized_block + text[block_end:]
+ path.write_text(text)
+
+patch_configs(
+ pathlib.Path(sys.argv[1]),
+ (
+ "FA0B78EF1A958B90000E1D17",
+ "FA0B78F01A958B90000E1D17",
+ "FA0B78F11A958B90000E1D17",
+ ),
+ (
+ "\t\t\t\tCLANG_WARN_UNINITIALIZED_AUTOS = NO;\n",
+ "\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = NO;\n",
+ "\t\t\t\tCLANG_WARN_UNUSED_PARAMETER = NO;\n",
+ "\t\t\t\tGCC_WARN_CHECK_SWITCH_STATEMENTS = NO;\n",
+ "\t\t\t\tGCC_WARN_SIGN_COMPARE = NO;\n",
+ "\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = NO;\n",
+ "\t\t\t\tGCC_WARN_UNUSED_FUNCTION = NO;\n",
+ "\t\t\t\tGCC_WARN_UNUSED_PARAMETER = NO;\n",
+ "\t\t\t\tGCC_WARN_UNUSED_VARIABLE = NO;\n",
+ "\t\t\t\tOTHER_CFLAGS = \"$(inherited) -Wno-sign-compare -Wno-strict-prototypes -Wno-unused-but-set-variable -Wno-unused-function -Wno-unused-parameter -Wno-unused-variable\";\n",
+ "\t\t\t\tOTHER_CPLUSPLUSFLAGS = \"$(inherited) -Wno-deprecated-declarations -Wno-non-c-typedef-for-linkage -Wno-sign-compare -Wno-switch -Wno-unguarded-availability-new -Wno-unused-but-set-variable -Wno-unused-function -Wno-unused-parameter -Wno-unused-private-field -Wno-unused-variable\";\n",
+ ),
+)
+patch_configs(
+ pathlib.Path(sys.argv[2]),
+ (
+ "FA0B7F261A95AAF4000E1D17",
+ "FA0B7F271A95AAF4000E1D17",
+ "FA0B7F281A95AAF4000E1D17",
+ ),
+ (
+ "\t\t\t\tCLANG_WARN_UNDECLARED_SELECTOR = NO;\n",
+ "\t\t\t\tCLANG_WARN_UNUSED_PARAMETER = NO;\n",
+ "\t\t\t\tOTHER_CFLAGS = \"$(inherited) -Wno-undeclared-selector -Wno-unused-parameter\";\n",
+ ),
+)
+PY
+}
+
# --------------------------------------------------------------- xcodebuild
# love.system.pickFile and createFile are a native bridge compiled in by
# mobile/ios/patch_love_src.py, not part of LÖVE. A build that skipped the
@@ -512,6 +612,7 @@ run_xcodebuild() {
MARKETING_VERSION="$marketing_version"
CURRENT_PROJECT_VERSION="$project_version"
ONLY_ACTIVE_ARCH=NO
+ DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
)
if ! $DEVICE; then
@@ -671,6 +772,7 @@ python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
ensure_manifests
pack_game_love
ensure_game_love_in_xcode
+suppress_love_dependency_warnings
if $PACKAGE_ONLY; then
say "package-only: skipping xcodebuild (game.love + plist ready under mobile/ios/love-src/)"
diff --git a/scripts/test.sh b/scripts/test.sh
index 62a73349..ed3ac9d9 100755
--- a/scripts/test.sh
+++ b/scripts/test.sh
@@ -123,6 +123,7 @@ if [ -f data/generated/maps.lua ]; then
run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
+ run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
fi
else
diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua
index dfa915e7..a0b138b8 100644
--- a/src/battle/BattleState.lua
+++ b/src/battle/BattleState.lua
@@ -23,6 +23,7 @@ local Pokemon = require("src.pokemon.Pokemon")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Status = require("src.battle.Status")
+local Timing = require("src.core.Timing")
local TrainerAI = require("src.battle.TrainerAI")
local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart")
@@ -32,10 +33,79 @@ local WideBattle = require("src.battle.WideBattle")
local BattleState = {}
BattleState.__index = BattleState
BattleState.isOpaque = true
+
+-- pokered prints the battle lines itself (engine/battle/core.asm and the
+-- move-effect banks), and the importer extracts every one of them, so the
+-- port paraphrasing them in Lua meant the screen showed a near-miss of the
+-- game's own wording while the cache held the real line -- and on a
+-- localized import it showed English over translated data.
+--
+-- fromRom prefers the extracted text and keeps the literal as the catalog
+-- fallback, for a cache built before the label and for the pure-module
+-- tests that run without a dataset. The battle text's slots ({USER},
+-- {TARGET}, the {RAM:...} buffers) are NOT in the token registry that
+-- TextBox.substitute serves -- it only resolves {PLAYER}, {RIVAL} and
+-- three string buffers -- so they are spliced here, in argument order,
+-- before the box ever sees the string. {PLAYER}/{RIVAL} are left alone
+-- for that later pass.
+-- {PLAYER}/{RIVAL} are the two slots TextBox.substitute can fill on its
+-- own, so they are only consumed here when the caller clearly supplies
+-- them: an argument count matching every slot. Matching just the other
+-- slots leaves those two for the later pass. Anything else means the
+-- extracted line cannot carry what the call has to say -- a few labels
+-- stop at a dynamic marker the decoder does not follow, e.g.
+-- _EnemysWeakText extracts as "The enemy's weak!\nGet'm! " with nowhere
+-- to put the name -- so the engine's own wording stands in rather than
+-- printing a sentence with a hole in it.
+local function fromRom(data, label, fallback, ...)
+ local text = data and data.text and data.text[label]
+ if not text then return Strings(fallback, ...) end
+ local args = { ... }
+ if #args == 0 then return text end
+
+ local slots, named = 0, 0
+ for token in text:gmatch("%b{}") do
+ slots = slots + 1
+ if token == "{PLAYER}" or token == "{RIVAL}" then named = named + 1 end
+ end
+ local fillNamed
+ if #args == slots then
+ fillNamed = true
+ elseif #args == slots - named then
+ fillNamed = false
+ else
+ return Strings(fallback, ...)
+ end
+
+ local index = 0
+ return (text:gsub("%b{}", function(token)
+ if not fillNamed and (token == "{PLAYER}" or token == "{RIVAL}") then
+ return token
+ end
+ index = index + 1
+ local value = args[index]
+ if value == nil then return token end
+ return tostring(value)
+ end))
+end
+
+function BattleState:romText(label, fallback, ...)
+ return fromRom(self.data, label, fallback, ...)
+end
-- Letterbox voids around the 160x144 battle canvas fill white so the
-- window reads as one continuous battle screen (no black bars).
BattleState.letterboxWhite = true
+-- A battle is a self-contained SCREEN, not the window. The overworld's
+-- dialogue box docks to the window edge on purpose (Renderer:setUIAnchor) --
+-- a box floating in the middle of a zoomed-out map reads as detached. A
+-- battle is the opposite: pokered draws its text box and YES/NO in the same
+-- 160x144 tilemap as the HUD, and pulling them out to the window edge splits
+-- the composition in two -- the caught-mon nickname prompt lands a whole
+-- letterbox below the white field it is supposed to be printed on. Anchors
+-- are held off for as long as a battle is in the stack.
+BattleState.holdsUIAnchors = true
+
-- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on
-- a 304x144 surface (src/battle/WideBattle.lua). Only the composition
-- differs; every battler, queue and animation below is shared. Menus and
@@ -50,6 +120,41 @@ function BattleState:wideLayout()
return self:isWideBattleLayout()
end
+-- BATTLE SIZE: "fixed" keeps the classic integer-scaled letterbox (a GB pixel
+-- is a whole number of screen pixels, and the battle is the same size at any
+-- zoom); "fill" scales the battle surface to the window instead, so it fills
+-- vertically. Filling means a fractional scale, so pixels stop being evenly
+-- sized -- that is the trade, which is why it is a setting rather than a
+-- change. Only the battle surface is affected; the overworld is unchanged.
+function BattleState:wantsFillScale()
+ local options = self.game and self.game.save and self.game.save.options
+ return options and options.battleFit == "fill" or false
+end
+
+-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
+-- that grow as the window gets bigger or the view is zoomed out. The battle
+-- screen itself is untouched: it keeps its white paper field in every mode.
+--
+-- "white" the display mode's paper shade (the classic look)
+-- "black" plain black bars
+-- "world" the frozen overworld, dimmed
+--
+-- "world" works by making the battle NON-opaque: StateStack:visibleBase then
+-- finds the overworld below it and Game:draw keeps drawing the map, so the
+-- voids show it instead of a flat clear. The battle still paints its own
+-- opaque 160x144 field over the top, so only the surround changes.
+function BattleState:bgMode()
+ local options = self.game and self.game.save and self.game.save.options
+ local mode = options and options.battleBg
+ if mode == "black" or mode == "world" then return mode end
+ return "white"
+end
+
+-- How far to dim the overworld behind a "world" background, 0..1. Enough
+-- that the battle reads as the foreground rather than competing with a fully
+-- lit map behind it.
+BattleState.BG_WORLD_DIM = 0.55
+
-- Renderer:setUISize asks the top state for its surface before anything draws
function BattleState:uiSize()
if self:wideLayout() then return WideBattle.WIDTH, WideBattle.HEIGHT end
@@ -523,9 +628,9 @@ function BattleState.newWild(game, species, level, opts)
self.enemy = makeBattler(game.data, Pokemon.new(game.data, species, level), false)
markSeen(game, species)
if opts and opts.hooked then
- self.introText = Strings("The hooked\n%s\nattacked!", self.enemy.name)
+ self.introText = self:romText("_HookedMonAttackedText", "The hooked\n%s\nattacked!", self.enemy.name)
else
- self.introText = Strings("Wild %s\nappeared!", self.enemy.name)
+ self.introText = self:romText("_WildMonAppearedText", "Wild %s\nappeared!", self.enemy.name)
end
return self
end
@@ -707,7 +812,7 @@ function BattleState:queueScopeReveal()
or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!"))
self:act(function() self.ghostReveal = { t = 0 } end)
table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES })
- self:say(Strings("Wild %s\nappeared!",
+ self:say(self:romText("_WildMonAppearedText", "Wild %s\nappeared!",
self.ghostReal and self.ghostReal.name or self.enemy.name))
end
@@ -844,9 +949,30 @@ function BattleState:drainNext(battler, stopAt)
{ drain = true, battler = battler, stopAt = stopAt })
end
--- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar):
--- the bar animates a pixel per two frames, so displayed HP moves at
--- maxHP/96 per frame (48-pixel bar). Returns true while animating.
+-- Queue a pure frame hold at the current insert point, the way the original
+-- spends DelayFrames between the beats of a turn. Mirrors sayNext/drainNext
+-- so a caller can interleave holds with messages in source order.
+function BattleState:waitNext(frames)
+ if not frames or frames <= 0 then return end
+ self.nextInsert = (self.nextInsert or 0) + 1
+ table.insert(self.queue, self.nextInsert, { wait = frames })
+end
+
+-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar).
+--
+-- The original walks the bar ONE HP POINT per loop iteration (:81-120), and
+-- what each iteration costs depends on the side:
+-- * UpdateHPBar_PrintHPNumber spends a DelayFrame (:234) reprinting the
+-- number, but only when wHPBarType is nonzero (:207-209) -- the player's
+-- own HUD and the party menu, never the enemy's;
+-- * UpdateHPBar_AnimateHPBar spends 2 frames for each pixel the bar
+-- actually moved (:147-148), and most single-HP steps move none.
+-- So the player's bar drains at 1 HP per frame plus 2 frames per pixel,
+-- while the enemy's costs nothing until it crosses a pixel boundary. The
+-- old flat maxHP/96 rate was the enemy-side formula applied to both, which
+-- ran a 150 HP mon's full drain in 96 frames against hardware's 249.
+--
+-- Returns true while animating.
function BattleState:stepHPDrain()
local busy = false
for _, b in ipairs({ self.player, self.enemy }) do
@@ -857,14 +983,30 @@ function BattleState:stepHPDrain()
and b.shownHP >= b.drainFloor then
goal = b.drainFloor
end
- if b.shownHP ~= goal then
- local step = math.max(1, b.mon.stats.hp) / 96
- if b.shownHP > goal then
- b.shownHP = math.max(goal, b.shownHP - step)
- else
- b.shownHP = math.min(goal, b.shownHP + step)
+ if (b.drainHold or 0) > 0 then
+ b.drainHold = b.drainHold - 1
+ busy = true
+ elseif b.shownHP ~= goal then
+ local maxHP = math.max(1, b.mon.stats.hp)
+ local playerSide = (b == self.player)
+ local cost = 0
+ -- consume whole HP steps until this frame's budget is spent; on the
+ -- enemy HUD several free steps can land in the same frame
+ while b.shownHP ~= goal and cost < 1 do
+ local nextHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
+ cost = cost + Timing.hpDrainStepFrames(b.shownHP, nextHP,
+ maxHP, playerSide)
+ b.shownHP = nextHP
end
- busy = busy or b.shownHP ~= goal
+ b.drainHold = math.max(0, cost - 1)
+ b.draining = true
+ busy = true
+ elseif b.draining then
+ -- .animateHPBarDone's final number print, one more pixel step and
+ -- Delay3 (hp_bar.asm:132-135); this frame is the first of them
+ b.draining = nil
+ b.drainHold = Timing.hpDrainClosingFrames(b == self.player) - 1
+ busy = true
end
end
end
@@ -939,6 +1081,14 @@ function BattleState:updateQueue()
self.waitFrames = self.waitFrames - 1
return true
end
+ -- WaitForSoundToFinish (home/delay.asm:15-20) blocks until the sfx has
+ -- actually stopped sounding, which is how the original gives a sound its
+ -- own clear window instead of letting the next beat play over it
+ if self.waitingSound then
+ local src = self.waitingSound
+ if src and src.isPlaying and src:isPlaying() then return true end
+ self.waitingSound = nil
+ end
-- an HP-bar drain holds the queue until the bar catches up
if self.draining then
if self:stepHPDrain() then return true end
@@ -990,6 +1140,12 @@ function BattleState:updateQueue()
self.waitFrames = item.wait
return true
end
+ if item.waitSound then
+ -- the source is fetched now, not when the row was queued, so the
+ -- act() that started the sound has already run
+ self.waitingSound = item.waitSound()
+ return true
+ end
if item.mimicSelect then
-- pause the queue on Mimic's copy menu (MoveSelectionMenu with
-- wMoveMenuType = 1 lists the enemy's moves; cursor starts on 1)
@@ -1010,6 +1166,16 @@ function BattleState:updateQueue()
-- the animation ends (hitRow rows carry a hit with no animation --
-- thrash/rage continuation turns that skip the announcement).
if item.anim or item.hitRow then
+ -- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
+ -- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass
+ -- between the move's announcement and the first frame of its
+ -- animation. Put the row back and pay that first.
+ if item.anim and not item.animDelayed then
+ item.animDelayed = true
+ table.insert(self.queue, 1, item)
+ self.waitFrames = Timing.MOVE_ANIM_PRE
+ return true
+ end
local mdef = item.anim and self.data.moves[item.anim]
local anim = mdef and mdef.anim
if item.anim == "POOF_ANIM" then
@@ -1081,17 +1247,38 @@ function BattleState:updateQueue()
-- a \v CONT wait holds the box until A/B, then scrolls the next line in
-- (home/text.asm ContText); this keeps a 3rd line on-screen (#216)
if self.msgWaiting then
+ -- _ContText prints the â–¼ and runs ProtectedDelay3 BEFORE ManualTextScroll
+ -- starts watching the joypad (home/text.asm:263-267), so three frames
+ -- pass with the arrow up and the button ignored
+ if (self.msgPreWait or 0) > 0 then
+ self.msgPreWait = self.msgPreWait - 1
+ return true
+ end
if input:wasPressed("a") or input:wasPressed("b") then
self.msgWaiting = nil
self:beginMsgLine()
+ -- then the two ScrollTextUpOneLine calls block for 5 frames each
+ -- (home/text.asm:280-305) before the next line starts typing
+ self.waitFrames = Timing.TEXT_SCROLL_PAIR
end
return true
end
local cur = self.shown[#self.shown]
if #cur < #self.codes then
- -- battle typewriter cadence: two glyphs per fixed step (as before)
- for _ = 1, 2 do
- if #cur >= #self.codes then break end
+ -- Battle text prints through the same PrintText path as everything
+ -- else, so it pays PrintLetterDelay per character (home/print_text.asm:
+ -- 4-45): hFrameCounter is loaded from wOptions & $f -- the OPTION text
+ -- speed, 1/3/5, default 3 -- and the loop spins until it drains, unless
+ -- A or B is held, which collapses the wait to a single DelayFrame.
+ -- This used to run two glyphs per frame flat, six times hardware speed
+ -- at the default setting, and ignored the text-speed option entirely.
+ local delay = (self.game.save.options and self.game.save.options.textSpeed)
+ or 3
+ if delay ~= 1 and delay ~= 3 and delay ~= 5 then delay = 3 end
+ if input:isDown("a") or input:isDown("b") then delay = 1 end
+ self.charTimer = (self.charTimer or 0) + 1
+ while self.charTimer >= delay and #cur < #self.codes do
+ self.charTimer = self.charTimer - delay
cur[#cur + 1] = self.codes[#cur + 1]
self.charIndex = self.charIndex + 1
end
@@ -1100,6 +1287,7 @@ function BattleState:updateQueue()
-- scrolling, \n advances now (beginMsgLine scrolls if the box is full)
if self.lines[self.lineIndex + 1].cont then
self.msgWaiting = true
+ self.msgPreWait = Timing.TEXT_PRE_ADVANCE
else
self:beginMsgLine()
end
@@ -1126,8 +1314,18 @@ function BattleState:updateQueue()
-- only on a \v CONT hold (#317). A flag of its own, not msgWaiting:
-- that branch above scrolls the NEXT line in, which this page has not
-- got, so reusing it would call beginMsgLine on a drained message.
- self.msgPrompt = true
- if input:wasPressed("a") or input:wasPressed("b") then
+ if not self.msgPrompt then
+ self.msgPrompt = true
+ -- PromptText runs ProtectedDelay3 between writing the arrow and
+ -- ManualTextScroll (home/text.asm:213-217), so the page holds for
+ -- three frames with the button ignored before it can be dismissed.
+ -- Without it a queued A press could clear a page the same frame its
+ -- last glyph landed, which is most of "it doesn't hold sometimes".
+ self.msgPromptWait = Timing.TEXT_PRE_ADVANCE
+ end
+ if (self.msgPromptWait or 0) > 0 then
+ self.msgPromptWait = self.msgPromptWait - 1
+ elseif input:wasPressed("a") or input:wasPressed("b") then
self.msgPrompt = nil
self.current = nil
end
@@ -1153,7 +1351,7 @@ function BattleState:sendOutText(name)
if pct >= 70 then return Strings("Go! %s!", name) end
if pct >= 40 then return Strings("Do it! %s!", name) end
if pct >= 10 then return Strings("Get'm! %s!", name) end
- return Strings("The enemy's weak!\nGet'm! %s!", name)
+ return self:romText("_EnemysWeakText", "The enemy's weak!\nGet'm! %s!", name)
end
-- audio/play_battle_music.asm: gym leaders (wGymLeaderNo) get the
@@ -1248,11 +1446,12 @@ function BattleState:enter()
-- without a transition (link battles, scripted pushes)
Music.playBattle(self.data, self.musicKind)
-- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both
- -- sides slide in as black silhouettes. The original scrolls SCX from
- -- $90 to 0 two pixels per frame (72 frames); the port covers the full
- -- 160px screen width, so 2px/frame is an 80-frame slide (slide offset is
- -- introSlide*2 below). The trainer pics stay up until the send-outs.
- self.introSlide = 80
+ -- sides slide in; the trainer pics stay up until the send-outs
+ -- BATTLE BG "world" drops this battle's opacity so StateStack keeps drawing
+ -- the overworld underneath it (see bgMode). Per instance, so the class
+ -- default stays opaque for every other battle and for older saves.
+ self.isOpaque = self:bgMode() ~= "world"
+ self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
@@ -1298,6 +1497,25 @@ function BattleState:enter()
and not self.ghost and not self.scopeReveal then
queueEnemyCry()
end
+ -- PrintBeginningBattleText .trainerBattle (common_text.asm): a trainer
+ -- battle gives SFX_SILPH_SCOPE a clear window -- PlaySound, then
+ -- WaitForSoundToFinish, which blocks -- and only after `ld c, 20 /
+ -- DelayFrames` do DrawAllPokeballs and the "wants to fight!" text run.
+ -- The balls and the text used to appear on the same frame the silhouettes
+ -- landed, so the sound had to share its whole duration with the ball draw
+ -- and the text scroll instead of landing on its own.
+ --
+ -- The sfx is extracted as "Trainer_Appeared" (tools/rom_manifest.json
+ -- sfxHeaders, bank 8 / $42bb -- the same header pokered names
+ -- SFX_Silph_Scope); nothing had ever played it.
+ if self.kind == "trainer" then
+ self:act(function()
+ self.introSfx = require("src.core.Sound").play(self.data,
+ "Trainer_Appeared")
+ end)
+ table.insert(self.queue, { waitSound = function() return self.introSfx end })
+ table.insert(self.queue, { wait = Timing.TRAINER_INTRO_SFX_GAP })
+ end
self:say(self.introText)
-- the unveil rides on that same box, before _InitBattleCommon clears the
-- intro chrome below (#492)
@@ -1317,12 +1535,21 @@ function BattleState:enter()
table.insert(self.queue, { wait = 16 })
self:act(function()
self.showEnemyTrainer = false
+ -- the slot is EMPTY from here until AnimateSendingOutMon runs below:
+ -- the pic has walked off and the mon is still in its ball, so nothing
+ -- stands in the enemy slot while TrainerSentOutText prints. Without
+ -- this the front sprite popped in full-size the instant the trainer
+ -- left, sat there through the whole text box, and the grow-in then
+ -- restarted it from nothing -- the mon appearing before it was sent
+ -- out. Mirrors the flag the mid-battle replacement already sets.
+ self.enemySendingOut = true
self:slidePic("foe")
end)
self:say(Strings("%s sent\nout %s!", self.trainer.name, 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
+ self.enemySendingOut = false
self:startGrowIn(self.enemy)
end)
queueEnemyCry()
@@ -1339,6 +1566,14 @@ function BattleState:enter()
end)
queueEnemyCry()
end
+ -- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz`
+ -- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames`
+ -- after it is unconditional, so a wild battle pays it too, between
+ -- "Wild X appeared!" and "Go! Y!". It lands before .playerSendOutFirstMon
+ -- (:166), not at the end of the intro. Appended, not waitNext'd: the
+ -- intro is built linearly, and waitNext's insert point is for rows added
+ -- while the queue is already running.
+ table.insert(self.queue, { wait = Timing.BATTLE_START_SENDOUT })
if not self.safari and not self.demo then
-- StartBattle .playerSendOutFirstMon (core.asm:236-240): the back pic
-- walks off the LEFT edge (SlideTrainerPicOffScreen, hlcoord 1,5,
@@ -1690,11 +1925,11 @@ function BattleState:update(dt)
end
local mv = moves[self.moveIndex]
if self.player.disabledSlot == self.moveIndex then
- self:say(Strings("The move is\ndisabled!"))
+ self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!"))
self.phase = "messages"
self.afterQueue = "menu"
elseif mv.pp <= 0 then
- self:say(Strings("No PP left for\nthis move!"))
+ self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!"))
self.phase = "messages"
self.afterQueue = "menu"
else
@@ -1744,7 +1979,7 @@ function BattleState:resolveMimic(user, target, move, moveInst)
table.insert(self.queue, self.nextInsert, { wait = 50 })
if target.invulnerable
or not self:accuracyRoll(move, user, target) then
- self:sayNext(Strings("But, it failed!"))
+ self:sayNext(self:romText("_ButItFailedText", "But, it failed!"))
return
end
local slots = {}
@@ -1754,7 +1989,7 @@ function BattleState:resolveMimic(user, target, move, moveInst)
if #slots == 0 then
-- .getRandomMove rerolls empty slots forever; a moveless target
-- can't happen in practice, so just fail instead of hanging
- self:sayNext(Strings("But, it failed!"))
+ self:sayNext(self:romText("_ButItFailedText", "But, it failed!"))
return
end
if user.isPlayer and self.kind ~= "link" then
@@ -1815,7 +2050,7 @@ function BattleState:applyMimic(user, target, moveInst, slot)
entry.mimic = true
self:animNext("MIMIC", user.isPlayer)
-- _MimicLearnedMoveText: " / learned / MOVE!"
- self:sayNext(Strings("%s\nlearned\n%s!", displayName(user),
+ self:sayNext(self:romText("_MimicLearnedMoveText", "%s\nlearned\n%s!", displayName(user),
self.data.moves[src.id].name))
end
@@ -2510,24 +2745,27 @@ function BattleState:applyHitFx(hit)
prog[#prog + 1] = { dy = 0, frames = 3 }
end
self.fx.shakeProg = prog
- self.waitFrames = 48 -- the predef blocks until the shake settles
+ self.waitFrames = Timing.SHAKE_VERTICAL -- the predef blocks until it settles
elseif t == 2 then
self.fx.shakeProg = fastShakeProg(8)
- self.waitFrames = 72
+ self.waitFrames = Timing.SHAKE_HORIZ_HEAVY
elseif t == 3 then
self.fx.shakeProg = slowShakeProg(6, 2)
- self.waitFrames = 48
+ self.waitFrames = Timing.SHAKE_HORIZ_SLOW
elseif t == 4 then
if hit.blink then
- self.fx.blink = { target = hit.blink, frames = 20 }
- self.waitFrames = 20
+ -- AnimationBlinkMon: 6 iterations of hide/5 frames/show/5 frames.
+ -- This is the animation for every plain damaging move the player
+ -- uses, and it ran at a third of its length.
+ self.fx.blink = { target = hit.blink, frames = Timing.BLINK_MON }
+ self.waitFrames = Timing.BLINK_MON
end
elseif t == 5 then
self.fx.shakeProg = fastShakeProg(2)
- self.waitFrames = 18
+ self.waitFrames = Timing.SHAKE_HORIZ_LIGHT
elseif t == 6 then
self.fx.shakeProg = slowShakeProg(3, 2)
- self.waitFrames = 24
+ self.waitFrames = Timing.SHAKE_HORIZ_SLOW2
end
end
@@ -2874,7 +3112,7 @@ function BattleState:executeAction(user, target, action)
-- 3392): sleep/freeze/held/flinch keep the mon recharging next turn
if self:preRechargeChecks(user, target) then return end
user.mustRecharge = nil
- self:sayNext(Strings("%s\nmust recharge!", displayName(user)))
+ self:sayNext(self:romText("_MustRechargeText", "%s\nmust recharge!", displayName(user)))
return
end
if action.special == "bound" then
@@ -2919,8 +3157,8 @@ function BattleState:statusOnomatopoeia(user, kind)
anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM"
end
local text = kind == "sleep"
- and Strings("%s\nis fast asleep!", displayName(user))
- or Strings("%s\nis confused!", displayName(user))
+ and self:romText("_FastAsleepText", "%s\nis fast asleep!", displayName(user))
+ or self:romText("_IsConfusedText", "%s\nis confused!", displayName(user))
if kind == "sleep" and isPlayer then
self:animNext(anim, isPlayer)
self:sayNext(text)
@@ -2959,18 +3197,18 @@ function BattleState:preRechargeChecks(user, target)
user.sleepTurns = (user.sleepTurns or 1) - 1
if user.sleepTurns <= 0 then
mon.status = nil
- self:sayNext(Strings("%s\nwoke up!", displayName(user)))
+ self:sayNext(self:romText("_WokeUpText", "%s\nwoke up!", displayName(user)))
else
self:statusOnomatopoeia(user, "sleep")
end
return true
end
if mon.status == "FRZ" then
- self:sayNext(Strings("%s\nis frozen solid!", displayName(user)))
+ self:sayNext(self:romText("_IsFrozenText", "%s\nis frozen solid!", displayName(user)))
return true
end
if target.trappingTurns then
- self:sayNext(Strings("%s\ncan't move!", displayName(user)))
+ self:sayNext(self:romText("_CantMoveText", "%s\ncan't move!", displayName(user)))
return true
end
if user.flinched then
@@ -2978,7 +3216,7 @@ function BattleState:preRechargeChecks(user, target)
-- player recharges, so the flinch eats the recharge turn and the
-- flag survives (the Hyper Beam flinch glitch)
user.flinched = false
- self:sayNext(Strings("%s\nflinched!", displayName(user)))
+ self:sayNext(self:romText("_FlinchedText", "%s\nflinched!", displayName(user)))
return true
end
return false
@@ -2999,7 +3237,7 @@ function BattleState:statusInterrupt(user, target)
{ id = "CONFUSED", power = 40, type = "NORMAL", accuracy = 100 },
{ rng = self.rng, forceCrit = false, typeless = true,
screens = target })
- self:sayNext(Strings("It hurt itself in\nits confusion!"))
+ self:sayNext(self:romText("_HurtItselfText", "It hurt itself in\nits confusion!"))
self:clearVolatiles(user, true)
self:applyDamage(user, dmg)
if user.mon.hp <= 0 then self:onFaint(user) end
@@ -3087,7 +3325,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
self.moveAnimRow = nil
if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then
- self:sayNext(Strings("%s\nused %s!", displayName(user), move.name))
+ self:sayNext(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
-- the move's animation plays right after the announcement; the
-- damage path attaches the target's hit blink to this row so the
-- blink follows the animation (pokered's order). Mimic is the
@@ -3172,7 +3410,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
-- SleepEffect/PoisonEffect/... call PlayCurrentMoveAnimation only
-- after the effect lands; a miss skips it
self:cancelMoveAnim()
- self:sayNext(Strings("%s's\nattack missed!", displayName(user)))
+ self:sayNext(self:romText("_AttackMissedText", "%s's\nattack missed!", displayName(user)))
return
end
local msgs = record.run(ctx)
@@ -3192,7 +3430,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
if move.power == 0 and not (record and record.kind == "full") then
MoveEffects.warnUnknown(move.effect)
self:cancelMoveAnim()
- self:sayNext(Strings("But, it failed!"))
+ self:sayNext(self:romText("_ButItFailedText", "But, it failed!"))
return
end
@@ -3201,7 +3439,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
end
function BattleState:continueTrapping(user, target)
- self:sayNext(Strings("%s's\nattack continues!", displayName(user)))
+ self:sayNext(self:romText("_AttackContinuesText", "%s's\nattack continues!", displayName(user)))
-- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText
-- then jumps to GetPlayerAnimationType, so the trapping move's full
-- animation replays each locked turn (same damage, animation shown).
@@ -3227,12 +3465,12 @@ function BattleState:continueBide(user, target)
self:sayNext(Strings("%s\nis storing energy!", displayName(user)))
return
end
- self:sayNext(Strings("%s\nunleashed energy!", displayName(user)))
+ self:sayNext(self:romText("_UnleashedEnergyText", "%s\nunleashed energy!", displayName(user)))
local dmg = (user.bideDamage or 0) * 2
user.bideTurns, user.bideDamage = nil, nil
if dmg <= 0 then
self:cancelMoveAnim()
- self:sayNext(Strings("But, it failed!"))
+ self:sayNext(self:romText("_ButItFailedText", "But, it failed!"))
return
end
-- .UnleashEnergy (core.asm:3501-3529) re-points wPlayerMoveNum at BIDE
@@ -3255,9 +3493,9 @@ function BattleState:applyDamage(target, dmg)
target.substituteHP = target.substituteHP - dmg
if target.substituteHP <= 0 then
target.substituteHP = nil
- self:sayNext(Strings("%s's\nSUBSTITUTE broke!", displayName(target)))
+ self:sayNext(self:romText("_SubstituteBrokeText", "%s's\nSUBSTITUTE broke!", displayName(target)))
else
- self:sayNext(Strings("The SUBSTITUTE\ntook damage for\n%s!", displayName(target)))
+ self:sayNext(self:romText("_SubstituteTookDamageText", "The SUBSTITUTE\ntook damage for\n%s!", displayName(target)))
end
return dmg
end
@@ -3269,7 +3507,7 @@ function BattleState:applyDamage(target, dmg)
end
if target.rageMove and dealt > 0 then
target.stages.attack = math.min(6, (target.stages.attack or 0) + 1)
- self:sayNext(Strings("%s's\nRAGE is building!", displayName(target)))
+ self:sayNext(self:romText("_BuildingRageText", "%s's\nRAGE is building!", displayName(target)))
end
return dealt
end
@@ -3305,10 +3543,13 @@ function BattleState:onFaint(battler)
Sound.playCry(self.data, battler.mon.species)
Sound.play(self.data, "Faint_Fall")
self.fx = self.fx or {}
- self.fx.faint = { battler = battler, frames = 30 }
+ -- SlideDownFaintedMonPic: PIC_HEIGHT (7) slide steps, each closing with
+ -- DelayFrames 2 (core.asm:1186-1222). The port held this one twice as
+ -- long as hardware.
+ self.fx.faint = { battler = battler, frames = Timing.FAINT_SLIDE }
end)
self.nextInsert = (self.nextInsert or 0) + 1
- table.insert(self.queue, self.nextInsert, { wait = 30 })
+ table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE })
if not battler.isPlayer and self.kind == "wild" then
-- FaintEnemyPokemon .wild_win (core.asm:792-795): beating a wild
-- mon calls EndLowHealthAlarm and starts MUSIC_DEFEATED_WILD_MON
@@ -3594,7 +3835,7 @@ function BattleState:enemyMonFainted()
-- scripted battles that print their own follow-up leave it nil.
self:actNext(function() self:playVictoryMusic() end)
-- _TrainerDefeatedText: " defeated\nTRAINER!"
- self:sayNext(Strings("%s defeated\n%s!", self.game.save.player.name,
+ self:sayNext(self:romText("_TrainerDefeatedText", "%s defeated\n%s!", self.game.save.player.name,
self.trainer.name))
self:actNext(function()
self.showEnemyTrainer = self.trainerPic ~= nil
@@ -3621,7 +3862,7 @@ function BattleState:enemyMonFainted()
end
end
end
- self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize))
+ self:sayNext(self:romText("_MoneyForWinningText", "%s got ¥%d\nfor winning!", self.game.save.player.name, prize))
end
self.result = "win"
self.afterQueue = "finish"
@@ -3637,7 +3878,7 @@ function BattleState:learnMove(mon, moveId)
if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
- self:sayNext(Strings("%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
+ self:sayNext(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
mdef.name))
return
end
@@ -3719,11 +3960,11 @@ function BattleState:playerMonFainted()
local pSpd = (game.save.party[1].stats or { speed = 0 }).speed or 0
if self:runRoll(pSpd, TurnOrder.effectiveSpeed(self.enemy)) then
require("src.core.Sound").play(self.data, "Run")
- self:say(Strings("Got away safely!"))
+ self:say(self:romText("_GotAwayText", "Got away safely!"))
self.result = "run"
self.afterQueue = "finish"
else
- self:say(Strings("Can't escape!"))
+ self:say(self:romText("_CantEscapeText", "Can't escape!"))
end
end)
end)
@@ -3743,7 +3984,7 @@ function BattleState:openReplacementMenu()
forceSwitch = true,
onSwitch = function(mon)
if mon.hp <= 0 then
- self:say(Strings("There's no will\nto fight!"))
+ self:say(self:romText("_NoWillText", "There's no will\nto fight!"))
return -- the menu-phase guard reopens the menu
end
self:restoreMimicked(self.player)
@@ -3787,7 +4028,7 @@ function BattleState:safariAction(choice)
if choice == "run" then
require("src.core.Sound").play(self.data, "Run")
- self:say(Strings("Got away safely!"))
+ self:say(self:romText("_GotAwayText", "Got away safely!"))
self.result = "run"
self.afterQueue = "finish"
return
@@ -3824,12 +4065,12 @@ function BattleState:safariAction(choice)
end
if choice == "bait" then
- self:say(Strings("%s threw some\nBAIT.", playerName))
+ self:say(self:romText("_ThrewBaitText", "%s threw some\nBAIT.", playerName))
self.safariCatchRate = math.floor(self.safariCatchRate / 2)
self.baitFactor = math.min(255, self.baitFactor + self.rng(1, 5))
self.escapeFactor = 0
else -- rock
- self:say(Strings("%s threw a\nROCK.", playerName))
+ self:say(self:romText("_ThrewRockText", "%s threw a\nROCK.", playerName))
self.safariCatchRate = math.min(255, self.safariCatchRate * 2)
self.escapeFactor = math.min(255, self.escapeFactor + self.rng(1, 5))
self.baitFactor = 0
@@ -3845,13 +4086,13 @@ end
function BattleState:safariEnemyTurn()
if self.baitFactor > 0 then
self.baitFactor = self.baitFactor - 1
- self:sayNext(Strings("Wild %s\nis eating!", self.enemy.name))
+ self:sayNext(self:romText("_SafariZoneEatingText", "Wild %s\nis eating!", self.enemy.name))
elseif self.escapeFactor > 0 then
self.escapeFactor = self.escapeFactor - 1
if self.escapeFactor == 0 then
self.safariCatchRate = self.enemy.def.catchRate
end
- self:sayNext(Strings("Wild %s\nis angry!", self.enemy.name))
+ self:sayNext(self:romText("_SafariZoneAngryText", "Wild %s\nis angry!", self.enemy.name))
end
self:act(function()
local speed = self.enemy.curStats.speed % 256
@@ -3867,7 +4108,7 @@ function BattleState:safariEnemyTurn()
fled = self.rng(0, 255) < b
end
if fled then
- self:sayNext(Strings("Wild %s\nran!", self.enemy.name))
+ self:sayNext(self:romText("_WildRanText", "Wild %s\nran!", self.enemy.name))
self:actNext(function()
require("src.core.Sound").play(self.data, "Run")
startPicKind(self:picFxFor(self.enemy), "slideOff")
@@ -3934,11 +4175,11 @@ function BattleState:tryRun()
TurnOrder.effectiveSpeed(self.enemy))
if escaped then
require("src.core.Sound").play(self.data, "Run")
- self:say(Strings("Got away safely!"))
+ self:say(self:romText("_GotAwayText", "Got away safely!"))
self.result = "run"
self.afterQueue = "finish"
else
- self:say(Strings("Can't escape!"))
+ self:say(self:romText("_CantEscapeText", "Can't escape!"))
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
@@ -3975,9 +4216,9 @@ function BattleState:ballMissMessage(shakes)
elseif shakes == 1 then
return t._ItemUseBallText02 or Strings("Darn! The POKéMON\nbroke free!")
elseif shakes == 2 then
- return (t._ItemUseBallText03 or Strings("Aww! It appeared\nto be caught!")):gsub("%s+$", "")
+ return (t._ItemUseBallText03 or self:romText("_ItemUseBallText03", "Aww! It appeared\nto be caught!")):gsub("%s+$", "")
end
- return t._ItemUseBallText04 or Strings("Shoot! It was so\nclose too!")
+ return t._ItemUseBallText04 or self:romText("_ItemUseBallText04", "Shoot! It was so\nclose too!")
end
-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
@@ -3988,7 +4229,7 @@ function BattleState:askNicknameUI(mon, displayName)
self.lockedBall = nil
self.blankForAskName = true
local TextBox = require("src.render.TextBox")
- local text = Strings("Do you want to\ngive a nickname\nto %s?", displayName)
+ local text = self:romText("_DoYouWantToNicknameText", "Do you want to\ngive a nickname\nto %s?", displayName)
local label = game.data.text and game.data.text._DoYouWantToNicknameText
if label then
-- extractor CONT is \t; TextBox scrolls on \n/\v
@@ -4117,7 +4358,7 @@ function BattleState:throwBall(ball)
-- " used - !" line (#291). Safari and the old man demo are
-- still wIsInBattle == 1, and this port models both as kind == "wild".
if self.kind == "wild" then
- self:say(Strings("%s used\n%s!", self.game.save.player.name,
+ self:say(self:romText("_ItemUseText001", "%s used\n%s!", self.game.save.player.name,
self.data.items[ball].name))
end
self:act(function()
@@ -4139,9 +4380,9 @@ function BattleState:throwBall(ball)
end)
self:animNext("BLOCKBALL_ANIM", true)
self:sayNext(t._ThrowBallAtTrainerMonText1
- or Strings("The trainer\nblocked the BALL!"))
+ or self:romText("_ThrowBallAtTrainerMonText1", "The trainer\nblocked the BALL!"))
self:sayNext(t._ThrowBallAtTrainerMonText2
- or Strings("Don't be a thief!"))
+ or self:romText("_ThrowBallAtTrainerMonText2", "Don't be a thief!"))
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
@@ -4204,7 +4445,7 @@ function BattleState:openParty()
if mon == self.player.mon then
self:say(Strings("%s is\nalready out!", self.player.name))
elseif mon.hp <= 0 then
- self:say(Strings("There's no will\nto fight!"))
+ self:say(self:romText("_NoWillText", "There's no will\nto fight!"))
else
self:resolveSwitch(mon)
end
@@ -4230,7 +4471,7 @@ end
function BattleState:finish()
if self.payDay and self.result == "win" then
self.game.save.money = self.game.save.money + self.payDay
- self:say(Strings("%s picked up\n¥%d!", self.game.save.player.name, self.payDay))
+ self:say(self:romText("_PickUpPayDayMoneyText", "%s picked up\n¥%d!", self.game.save.player.name, self.payDay))
self.payDay = nil
self.afterQueue = "finish"
self.phase = "messages"
@@ -4267,7 +4508,21 @@ function BattleState:finish()
require("src.core.Music").restoreMap(self.data)
self.game.stack:pop()
Runtime.emit("battle.ended", { battle = self, result = self.result or "run" })
- if self.onFinish then self.onFinish(self.result or "run") end
+ -- Coming back from the battle screen is a fade, not a cut: EnterMap sees
+ -- BIT_BATTLE_OVER_OR_BLACKOUT set and runs MapEntryAfterBattle
+ -- (home/overworld.asm:22, :749-753) = GBFadeInFromWhite. This is the one
+ -- choke point every battle -- wild, trainer, walk-up, scripted, link --
+ -- passes through on its way out, so the fade is guaranteed here rather
+ -- than depending on each caller having wrapped onFinish correctly.
+ local result = self.result or "run"
+ local onFinish = self.onFinish
+ if result == "lose" then
+ -- the blackout path warps to the heal point with its own transition
+ if onFinish then onFinish(result) end
+ return
+ end
+ self.game.stack:push(require("src.render.Transition").battleReturn(self.game,
+ function() if onFinish then onFinish(result) end end))
end
-- ---------------------------------------------------------------------
@@ -4323,10 +4578,14 @@ function BattleState:growInScale(battler)
end
-- battler hidden this frame? (damage blink)
+--
+-- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits
+-- DelayFrames 5, six times over (animations.asm:1360-1376) -- a 10-frame
+-- period, not 8. With Timing.BLINK_MON that is exactly six blinks.
function BattleState:fxHidden(battler)
local fx = self.fx
if fx and fx.blink and fx.blink.target == battler and fx.blink.frames > 0 then
- return self.frame % 8 < 4
+ return self.frame % 10 < 5
end
return false
end
@@ -4342,11 +4601,15 @@ end
-- pixels, so it scales with the pic's draw scale (the player's default 2x
-- sinks 2x as fast to sink at the same visual rate); a mod scale composes
-- the same way. scale defaults to the vanilla side scale when unknown.
+-- SlideDownFaintedMonPic drops the pic one 8px row per 2-frame step, so
+-- the offset advances Timing.FAINT_SLIDE_STEP (4px) per frame at 1x --
+-- the full 56px PIC_HEIGHT slide over the 14-frame budget (#671: the
+-- old (30 - frames) math teleported the sprite 32px down on frame one).
function BattleState:fxFaintOffset(battler, scale)
local fx = self.fx
if self:fxFaintActive(battler) then
scale = scale or (battler.isPlayer and 2 or 1)
- return (30 - fx.faint.frames) * 2 * scale
+ return (Timing.FAINT_SLIDE - fx.faint.frames) * Timing.FAINT_SLIDE_STEP * scale
end
return 0
end
@@ -5030,11 +5293,12 @@ function BattleState:drawHUDs(slide)
self:drawBallRow(self.enemyParty, 64, 16, -8)
end
- -- Safari shows only the ball count; the old man demo shows neither mon
- if self.safari then
- love.graphics.setColor(0, 0, 0, 1)
- Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72)
- end
+ -- A safari / old-man battle has no player mon out, so no player HUD (see
+ -- hidePlayer below). Nothing takes its place: PrintSafariZoneSteps -- the
+ -- "nnn/500 / BALLx nn" box -- is start-menu only and returns early off the
+ -- nine interior maps (engine/overworld/player_state.asm:219-224), so no
+ -- ball count belongs over the battlefield. The count lives in the BALL
+ -- menu item instead (drawTextArea, #540).
-- Party pokeball rows and the HUD chrome under them, for exactly the
-- window DrawAllPokeballs owns (common_text.asm:27, with the intro text).
-- SetupOwnPartyPokeballs runs in EVERY battle, so the player's row belongs
@@ -5139,6 +5403,11 @@ function BattleState:drawTextArea()
Font.drawBox(0, 12, 20, 6)
Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112)
Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN"), 112, 128)
+ -- DisplayBattleMenu .safariLeftColumn / .safariRightColumn print
+ -- wNumSafariBalls at hlcoord 7,14 with `lb bc, 1, 2` -- one byte, two
+ -- digits, space padded -- right after the "BALLx" label at columns
+ -- 2..6 (engine/battle/core.asm:2074-2079, 2107-2112) (#540)
+ Font.draw(("%2d"):format(self.safari.balls), 56, 112)
Font.drawCode(0xED, (col == 0 and 8 or 104), 112 + row * 16)
else
-- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT /
@@ -5230,7 +5499,8 @@ function BattleState:drawClassic()
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
sx = self.frame % 4 < 2 and 2 or -2
end
- local slide = (self.introSlide or 0) * 2 -- intro slide-in offset (2px/frame)
+ -- intro slide-in offset: 2 px per frame, so 144 px over 72 frames
+ local slide = (self.introSlide or 0) * Timing.BATTLE_SLIDE_PX_PER_FRAME
if self:colorMode() then
-- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the
diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua
index bc1544d5..e9d978e4 100644
--- a/src/battle/EffectRegistry.lua
+++ b/src/battle/EffectRegistry.lua
@@ -9,9 +9,22 @@ local MoveEffects = require("src.battle.MoveEffects")
local Runtime = require("src.mods.Runtime")
local StatusRegistry = require("src.battle.StatusRegistry")
local Strings = require("src.core.Strings")
+local Timing = require("src.core.Timing")
local EffectRegistry = {}
+-- A move that misses -- or that registers as missed, which in Gen 1 includes
+-- a type immunity and damage floored to zero -- skips its animation and
+-- falls into PlayerCheckIfFlyOrChargeEffect's `ld c, 30 / call DelayFrames`
+-- (engine/battle/core.asm:3155-3158 and :3185; enemy twin at :5588) before
+-- anything is printed. EXPLODE_EFFECT is the exception: core.asm:3157
+-- branches it to PlayPlayerMoveAnimation instead, so it pays the animation
+-- rather than the hold -- the same condition that gates cancelMoveAnim.
+local function missBeat(battle, record)
+ if record and record.explode then return end
+ battle:waitNext(Timing.MOVE_STATUS_OR_MISS)
+end
+
-- pokered's / text macros print "Enemy " before the enemy
-- mon's nickname (home/text.asm PlaceMoveUsersName)
local function displayName(b)
@@ -92,6 +105,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if target.invulnerable and not neverMiss then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end
+ missBeat(battle, record)
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
-- MoveHitTest's INVULNERABLE branch sets the same wMoveMissed as a
-- failed accuracy roll (core.asm:5260), and the miss handler still
@@ -120,6 +134,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if not battle:accuracyRoll(move, user, target) then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end
+ missBeat(battle, record)
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
-- Jump Kick crash, Explode self-destruct
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
@@ -147,6 +162,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end
if not counterable or (battle.lastDamage or 0) == 0 then
battle:cancelMoveAnim()
+ missBeat(battle, record)
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
return
end
@@ -170,6 +186,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if info.typeMult == 0 then
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
if not (record and record.explode) then battle:cancelMoveAnim() end
+ missBeat(battle, record)
battle:sayNext(Strings("It doesn't affect\n%s!", displayName(target)))
if record and record.onMiss then record.onMiss(ctx, "immune") end
return
@@ -177,6 +194,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if info.missed then
-- 0.25x floored the damage to zero: the original registers a miss
if not (record and record.explode) then battle:cancelMoveAnim() end
+ missBeat(battle, record)
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
if record and record.onMiss then record.onMiss(ctx, "floored") end
return
@@ -231,6 +249,13 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- every strike -- damage was only rolled once
if info.crit then battle:sayNext(Strings("Critical hit!")) end
if info.ohko then battle:sayNext(Strings("One-hit KO!")) end
+ -- PrintCriticalOHKOText closes with `ld c, 20 / jp DelayFrames` at its
+ -- .done label (core.asm:3812-3814) -- and the no-crit path jumps to that
+ -- same label (:3799), so this hold is paid on EVERY landed hit, not just
+ -- critical ones. It sits between the crit text and DisplayEffectiveness
+ -- (:3228-3229), which is where the beat before "It's super effective!"
+ -- comes from.
+ battle:waitNext(Timing.CRIT_OHKO_TEXT)
if info.typeMult > 10 then
battle:sayNext(Strings("It's super\neffective!"))
elseif info.typeMult < 10 then
diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua
index f6cf5699..8beb22f3 100644
--- a/src/battle/MoveEffects.lua
+++ b/src/battle/MoveEffects.lua
@@ -435,8 +435,18 @@ local function drainHalf(text)
end
end
--- fixed damage still respects type immunity (AdjustDamageForMoveType
--- flags the miss before the special-damage override)
+-- OHKO is the only "damage but not through normal calculations" family
+-- that still runs the type chart. CalculateDamage hands OHKO_EFFECT to
+-- JumpToOHKOMoveEffect (engine/battle/core.asm:4329) and, when the effect
+-- did not set wMoveMissed, execution falls through to
+-- AdjustDamageForMoveType (core.asm:3147), which multiplies the 65535 by
+-- the 0 matchup and flags the miss. Fixed damage and Super Fang do not:
+-- they are the SetDamageEffects table (data/battle/set_damage_effects.asm)
+-- and core.asm:3139 jumps straight to MoveHitTest, skipping
+-- CalculateDamage, AdjustDamageForMoveType and RandomizeDamage, while
+-- ApplyAttackToEnemyPokemon (core.asm:4612) writes wDamage with no
+-- effectiveness step. So in Gen 1 NIGHT_SHADE hits Normal-types and
+-- SUPER_FANG hits Ghosts, and only OHKO_EFFECT calls this (#616).
local function immuneMsg(ctx)
if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then
return Strings("It doesn't affect\n%s!", displayName(ctx.target))
@@ -466,8 +476,7 @@ MoveEffects.full = {
SPECIAL_DAMAGE_EFFECT = {
chooseDamage = function(ctx)
- local blocked = immuneMsg(ctx)
- if blocked then return nil, blocked end
+ -- no immunity check: SetDamageEffects skips AdjustDamageForMoveType (#616)
local dmg = fixedDamageFor(ctx)
if not dmg then return nil, "But, it failed!" end
return dmg, plainInfo()
@@ -475,8 +484,7 @@ MoveEffects.full = {
},
SUPER_FANG_EFFECT = {
chooseDamage = function(ctx)
- local blocked = immuneMsg(ctx)
- if blocked then return nil, blocked end
+ -- also SetDamageEffects: halves a Ghost's HP in Gen 1 (#616)
return math.max(1, math.floor(ctx.target.mon.hp / 2)), plainInfo()
end,
},
diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua
index fa455ae6..ad87b326 100644
--- a/src/battle/WideBattle.lua
+++ b/src/battle/WideBattle.lua
@@ -134,12 +134,13 @@ local function drawHUDs(battle, slide)
drawStatusPanel(battle, battle.enemy, 0, 0, false)
end
- if battle.safari then
- Font.drawBox(23, 7, 15, 4)
- love.graphics.setColor(0, 0, 0, 1)
- Font.draw(("BALLx%2d"):format(battle.safari.balls), 200, 72)
- elseif battle.player and not battle.demo and not battle.showPlayerBack
- and slide == 0 then
+ -- No player status panel in a safari / old-man battle: no mon of the
+ -- player's is out. Nothing replaces it either -- the ball count is a menu
+ -- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside
+ -- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in
+ -- drawCommandMenu below like the classic layout's (#540).
+ if not battle.safari and battle.player and not battle.demo
+ and not battle.showPlayerBack and slide == 0 then
drawStatusPanel(battle, battle.player, 184, 56, true)
end
drawIntroBalls(battle)
@@ -172,6 +173,9 @@ local function drawCommandMenu(battle)
Font.drawBox(0, 13, 38, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("BALLx"), 16, 112)
+ -- wNumSafariBalls immediately after the label, as at hlcoord 7,14
+ -- (engine/battle/core.asm:2074-2079) (#540)
+ Font.draw(("%2d"):format(battle.safari.balls), 56, 112)
Font.draw(Strings("BAIT"), 168, 112)
Font.draw(Strings("THROW ROCK"), 16, 128)
Font.draw(Strings("RUN"), 168, 128)
@@ -320,7 +324,9 @@ function WideBattle.draw(battle)
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
sx = battle.frame % 4 < 2 and 2 or -2
end
- local slide = (battle.introSlide or 0) * 2
+ -- same 2 px/frame silhouette slide the 160px layout uses
+ local slide = (battle.introSlide or 0)
+ * require("src.core.Timing").BATTLE_SLIDE_PX_PER_FRAME
-- Each side keeps its original sprite pixels and placement math: the two
-- 160x144 OAM regions are translated apart and clipped into the wider
diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua
index a7049f5d..91bcea8b 100644
--- a/src/core/ChipAudio.lua
+++ b/src/core/ChipAudio.lua
@@ -418,16 +418,22 @@ function ChipAudio.newCry(data, species, resolved)
})
end
+-- Two channels for the same reason ChipSynth.renderEffectData renders stereo:
+-- a mono Source is spatialized by OpenAL at the listener position and spreads
+-- over every output an interface has (#626). The siren itself is unchanged,
+-- both channels carry the same sample.
function ChipAudio.newLowHealthAlarm()
local samples = math.floor(SAMPLE_RATE * 62 / 60)
- local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 1)
+ local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 2)
local phase = 0
for index = 0, samples - 1 do
local frame = math.floor(index * 60 / SAMPLE_RATE) % 31
local register = frame < 11 and 0x750 or 0x6EE
local frequency = 131072 / (2048 - register)
phase = (phase + frequency / SAMPLE_RATE) % 1
- data:setSample(index, (phase < 0.5 and 1 or -1) * 0.25)
+ local value = (phase < 0.5 and 1 or -1) * 0.25
+ data:setSample(index, 1, value)
+ data:setSample(index, 2, value)
end
return love.audio.newSource(data, "static")
end
diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua
index 1c62a182..fb29c218 100644
--- a/src/core/ChipSynth.lua
+++ b/src/core/ChipSynth.lua
@@ -808,9 +808,22 @@ local function soundData(engine, samples, channels)
return result
end
--- Render a one-shot effect (SFX/cry) to a mono SoundData, or nil when it is
--- too short to be audible. The caller wraps it in a static love.audio.Source
--- (a playback concern, hence not done here).
+-- Render a one-shot effect (SFX/cry) to a two-channel SoundData, or nil when
+-- it is too short to be audible. The caller wraps it in a static
+-- love.audio.Source (a playback concern, hence not done here).
+--
+-- The synthesis is mono (one summed value per frame, unlike the music path's
+-- sampleStereo), but the buffer is written stereo on purpose: OpenAL only
+-- spatializes 1-channel Sources, and a Source left at the default (0,0,0)
+-- position, exactly where the listener sits, is rendered as an ambient sound
+-- spread over EVERY output channel the device exposes at gains that differ
+-- from the front pair. On an interface with more than two outputs that put
+-- the SFX on outputs 5+6 as well, while the 2-channel music source
+-- (ChipAudio.playMusic) stayed on 1+2 (#626). Multi-channel buffers skip
+-- spatialization entirely and map onto the front pair, so duplicating the
+-- sample costs one buffer's memory and makes effects route exactly like
+-- music. Deliberately not sampleStereo: that honors the NR51 panning byte
+-- and would newly hard-pan any effect whose header issues command 0xEE.
local function renderEffectData(data, header, options)
if not header then return nil end
options = options or {}
@@ -825,8 +838,12 @@ local function renderEffectData(data, header, options)
values[count] = engine:sample()
end
if count < math.floor(SAMPLE_RATE / 100) then return nil end
- local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 1)
- for index = 1, count do result:setSample(index - 1, values[index]) end
+ local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 2)
+ for index = 1, count do
+ local value = values[index]
+ result:setSample(index - 1, 1, value)
+ result:setSample(index - 1, 2, value)
+ end
return result
end
diff --git a/src/core/Data.lua b/src/core/Data.lua
index 6abaeb50..c5232296 100644
--- a/src/core/Data.lua
+++ b/src/core/Data.lua
@@ -77,6 +77,13 @@ end
function Data:applyVersionedFieldData()
if require("src.core.GameVersion").isYellow() then
self.field.trades = copy(YELLOW_TRADES)
+ -- The old man's catch demo is a RATTATA in Yellow
+ -- (scripts/ViridianCity.asm ViridianCityOldManStartCatchTrainingScript
+ -- .SetupBattle: ld a, RATTATA / ld [wCurOpponent], a) but the Yellow
+ -- manifest inherited Red's WEEDLE field.oldManBattle (#617), so old
+ -- Yellow caches carry the wrong demo species too. The fixed import
+ -- manifest below stamps RATTATA for fresh imports.
+ self.field.oldManBattle = { species = "RATTATA", level = 5 }
end
end
diff --git a/src/core/FaithfulRes.lua b/src/core/FaithfulRes.lua
new file mode 100644
index 00000000..e1535c77
--- /dev/null
+++ b/src/core/FaithfulRes.lua
@@ -0,0 +1,220 @@
+-- Faithful resolution: lock the window to an exact integer multiple of the
+-- Game Boy's 160x144 screen, 1X through 4X.
+--
+-- At any other window size the renderer picks the largest integer scale that
+-- fits and letterboxes the remainder (Renderer:fitScale), so the game is
+-- already crisp -- what it is not is *exact*: there are bars, and at a wide
+-- window a lot of them. Locking the window to 160*N x 144*N removes the
+-- letterbox entirely, so the surface is the Game Boy screen and nothing else.
+--
+-- Persisted as save.options.faithfulRes (0 = OFF). Applied from OptionsMenu
+-- and on boot via Game:applyOptions. No-ops in headless stubs that lack
+-- love.window.
+--
+-- MOBILE takes the other route to the same place. There is no window to
+-- resize -- the window IS the screen, and it rotates -- so the lock caps the
+-- RENDER scale instead: the renderer draws the Game Boy screen at exactly N
+-- physical pixels per GB pixel and centres it, and the rest of the display
+-- stays black. Same promise as the desktop lock (a GB pixel is exactly N
+-- screen pixels, no more) reached by moving the picture rather than the
+-- window. This used to return false on the first line, so the row sat in
+-- OPTIONS on Android and iOS doing nothing at all.
+--
+-- Scale, not size, is also what makes rotation free: Renderer:fitScale runs
+-- every frame off the live drawable size, so portrait and landscape both get
+-- the same locked scale with the bars falling wherever the screen is longer.
+
+local FaithfulRes = {}
+
+FaithfulRes.WIDTH, FaithfulRes.HEIGHT = 160, 144
+FaithfulRes.LEVELS = { 0, 1, 2, 3, 4 }
+FaithfulRes.DEFAULT = 0
+
+-- mobile only: the locked scale in physical pixels per GB pixel, 0 for OFF.
+-- Renderer:fitScale reads it through FaithfulRes.scaleCap.
+FaithfulRes.mobileScale = 0
+
+-- conf.lua's floor for the resizable desktop window, restored when the lock
+-- is released. 1X and 2X are BELOW it, so the lock has to lower the minimum
+-- as well as set the size or LOVE clamps the window back up.
+FaithfulRes.MIN_W, FaithfulRes.MIN_H = 480, 360
+
+-- whether this module currently owns the window size
+FaithfulRes.locked = false
+
+-- The highest level this display can actually show.
+--
+-- On desktop it is 4: the levels are window sizes, and 4X is the ceiling the
+-- feature shipped with. On mobile there is no window to size, so a fixed
+-- 1..4 ladder is meaningless -- 4X is a quarter of a 1080p phone, and the
+-- levels the panel could really use are not on the list at all. Derive it
+-- from the screen instead, so a 1080x2400 phone offers up to 6X and the top
+-- of the ladder is the biggest exact-pixel picture it can draw.
+--
+-- OFF (0) is untouched by any of this and keeps doing exactly what it always
+-- did: the renderer fits and letterboxes as usual.
+function FaithfulRes.maxLevel()
+ -- Mobile is ON or OFF. A ladder of absolute multiples is a desktop idea --
+ -- there it names a window size you can see. On a phone the same number
+ -- means a different fraction of every device, and every level below the top
+ -- is just a smaller picture for no reason. ON means one thing instead:
+ -- lock the viewport to the Game Boy's 10:9 and size it to this screen.
+ if FaithfulRes.isMobile() then return 1 end
+ return 4
+end
+
+-- the selectable ladder for this display: OFF, then 1X..maxLevel
+function FaithfulRes.levels()
+ local out = { 0 }
+ for i = 1, FaithfulRes.maxLevel() do out[#out + 1] = i end
+ return out
+end
+
+function FaithfulRes.normalize(v)
+ v = math.floor(tonumber(v) or FaithfulRes.DEFAULT)
+ if v < 0 then return 0 end
+ local max = FaithfulRes.maxLevel()
+ if v > max then return max end
+ return v
+end
+
+function FaithfulRes.label(v)
+ v = FaithfulRes.normalize(v)
+ if v == 0 then return "OFF" end
+ -- mobile has one ON: the level is chosen from the display, not the player
+ if FaithfulRes.isMobile() then return "ON" end
+ return tostring(v) .. "X"
+end
+
+function FaithfulRes.cycle(v, dir)
+ local levels = FaithfulRes.levels()
+ local cur = 1
+ for i, level in ipairs(levels) do
+ if level == FaithfulRes.normalize(v) then cur = i break end
+ end
+ return levels[(cur - 1 + (dir or 1)) % #levels + 1]
+end
+
+function FaithfulRes.isMobile()
+ -- POKEPORT_FORCE_MOBILE=1: take the mobile branch on a desktop build, so the
+ -- scale lock can be seen and driven without a device. The window is still
+ -- resizable, which is the point -- drag it to a phone aspect, rotate it by
+ -- dragging the other way, and the lock has to hold through both. Only this
+ -- module reads isMobile, so the override cannot leak into anything else.
+ if os.getenv("POKEPORT_FORCE_MOBILE") == "1" then return true end
+ if not love or not love.system or not love.system.getOS then return false end
+ local osName = love.system.getOS()
+ return osName == "Android" or osName == "iOS"
+end
+
+-- Physical pixels per LOVE unit for the CURRENT window.
+--
+-- Deliberately NOT love.window.getDPIScale: that reports the display's
+-- scaling factor even when the window is not high-DPI aware, and conf.lua
+-- only sets t.window.highdpi on mobile. On a plain desktop window a unit IS
+-- a pixel, so dividing by the display scale just shrinks the window -- at
+-- 125% scaling a 2X request became 256x230 pixels, which Renderer:fitScale
+-- floors to 1, and 4X became 512x461, which floors to 3. That is exactly
+-- the "2X renders at 1X, 4X renders at 3X" this shipped with.
+--
+-- Measuring the ratio the window actually reports is correct in both worlds:
+-- 1 on a plain desktop window, the real scale on a high-DPI one.
+local function pixelsPerUnit()
+ local g = love and love.graphics
+ if not (g and g.getDimensions and g.getPixelDimensions) then return 1 end
+ local uw = tonumber((g.getDimensions()))
+ local pw = tonumber((g.getPixelDimensions()))
+ if not uw or not pw or uw <= 0 or pw <= 0 then return 1 end
+ return pw / uw
+end
+
+-- The window size in LOVE UNITS that puts 160*v x 144*v PHYSICAL pixels on
+-- screen.
+function FaithfulRes.size(v)
+ v = FaithfulRes.normalize(v)
+ if v == 0 then return nil end
+ local ratio = pixelsPerUnit()
+ return math.floor(FaithfulRes.WIDTH * v / ratio + 0.5),
+ math.floor(FaithfulRes.HEIGHT * v / ratio + 0.5)
+end
+
+-- Push the lock into the live window. Returns true when the window is
+-- locked afterwards.
+-- The largest WHOLE multiple of the Game Boy screen this display can hold.
+-- Integer, never fractional: a GB pixel has to be the same number of screen
+-- pixels in both axes or it is not pixel perfect, it is resampled.
+--
+-- The leftover is black bars, and on a tall phone there is a lot of it
+-- vertically -- that is simply what a 10:9 screen looks like on a 9:20
+-- display, and it is what an emulator shows too.
+function FaithfulRes.deviceScale()
+ local g = love and love.graphics
+ if not (g and g.getPixelDimensions) then return 1 end
+ local pw, ph = g.getPixelDimensions()
+ if not pw or not ph or pw <= 0 or ph <= 0 then return 1 end
+ return math.max(1, math.floor(math.min(pw / FaithfulRes.WIDTH,
+ ph / FaithfulRes.HEIGHT)))
+end
+
+-- The scale the renderer must lock to, or nil for "fit the window as usual".
+-- Only ever set on mobile: on desktop the window itself is the lock, so
+-- fitScale already lands on N and this would be a second, redundant one.
+--
+-- Always the device maximum. Anything less is a smaller picture for no gain,
+-- which is how the first cut ended up showing a postage stamp on a 1080p
+-- phone.
+function FaithfulRes.scaleCap()
+ if not FaithfulRes.locked then return nil end
+ if not FaithfulRes.isMobile() then return nil end
+ return FaithfulRes.deviceScale()
+end
+
+function FaithfulRes.apply(v)
+ v = FaithfulRes.normalize(v)
+ -- Mobile: lock the render scale instead of the window. The scale itself
+ -- comes from the display (deviceScale), not from v -- v only says whether
+ -- the lock is on. Nothing to restore on release: the renderer simply goes
+ -- back to filling the display.
+ if FaithfulRes.isMobile() then
+ FaithfulRes.locked = v > 0
+ FaithfulRes.mobileScale = FaithfulRes.locked and FaithfulRes.deviceScale() or 0
+ return FaithfulRes.locked
+ end
+ if not love or not love.window or not love.window.setMode
+ or not love.window.getMode then
+ return false
+ end
+ local curW, curH, flags = love.window.getMode()
+ flags = flags or {}
+
+ if v == 0 then
+ -- only touch the window if we were the one holding it: an OFF setting on
+ -- boot must not resize a window the player sized themselves
+ if not FaithfulRes.locked then return false end
+ flags.resizable = true
+ flags.minwidth, flags.minheight = FaithfulRes.MIN_W, FaithfulRes.MIN_H
+ love.window.setMode(curW, curH, flags)
+ FaithfulRes.locked = false
+ return false
+ end
+
+ local w, h = FaithfulRes.size(v)
+ -- An exact size and a desktop-fullscreen mode cannot both hold. The lock
+ -- is the more specific request, so it wins and drops fullscreen; VIDEO MODE
+ -- reads BORDERLESS until the player changes it, which then releases this.
+ flags.fullscreen = false
+ -- resizing by hand would silently break the lock, and nothing re-applies it
+ -- (there is no love.resize handler -- the renderer re-reads the size every
+ -- frame), so the window is fixed while locked rather than left draggable.
+ flags.resizable = false
+ flags.minwidth, flags.minheight = w, h
+ love.window.setMode(w, h, flags)
+ FaithfulRes.locked = true
+ return true
+end
+
+function FaithfulRes.applyOptions(opts)
+ return FaithfulRes.apply(opts and opts.faithfulRes)
+end
+
+return FaithfulRes
diff --git a/src/core/FixedStep.lua b/src/core/FixedStep.lua
index 39cd980f..792df70a 100644
--- a/src/core/FixedStep.lua
+++ b/src/core/FixedStep.lua
@@ -7,9 +7,22 @@ local FixedStep = {}
FixedStep.STEP = 1 / 60
local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall
+-- Phase the accumulator is re-seeded with once an absorbed hitch frame has
+-- been paid for. Half a step is the balanced point: a frame has to come in
+-- ~8ms short before it drops a step and ~8ms long before it doubles one up.
+-- Zero -- what this used to leave behind -- has no margin at all on the long
+-- side, so the accumulator settles a hair BELOW one step and parks there, and
+-- ordinary sub-millisecond vsync wobble then flips it between 0 and 2 steps
+-- every few frames for the rest of the session. That is why pacing stayed
+-- visibly broken after a route/city seam and nowhere else: crossConnection in
+-- src/world/OverworldController.lua is the only caller of discardCatchup, and
+-- warps go through Transition instead (issue #487).
+local RESEED_PHASE = 0.5
+
function FixedStep:init(callback)
self.accum = 0
self.callback = callback
+ self.suppressCatchup = false
end
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
@@ -20,6 +33,16 @@ end
FixedStep.maxAccum = MAX_ACCUM
function FixedStep:update(dt)
+ -- A hitch's oversized dt lands on the frame AFTER discardCatchup was
+ -- called (the hitch itself already ran inside the current step); absorb
+ -- that one frame as a single step instead of the normal accumulator so
+ -- the burst it would otherwise release doesn't play out as a slide.
+ if self.suppressCatchup then
+ self.suppressCatchup = false
+ self.accum = self.STEP * RESEED_PHASE
+ self.callback(self.STEP)
+ return
+ end
self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM)
while self.accum >= self.STEP do
self.accum = self.accum - self.STEP
@@ -27,12 +50,14 @@ function FixedStep:update(dt)
end
end
--- Drop any pending catch-up steps. A hitch inside one logic step (map
--- seam setMap / song start) makes the next real-time dt huge; without this
--- the while-loop above would advance many walk frames before the next
--- draw, which looks like a slide with no leg animation (issue #93).
+-- Drop any pending catch-up steps and arm the one-frame clamp above. A
+-- hitch inside one logic step (map seam setMap / song start) makes the
+-- next real-time dt huge; without this the while-loop above would advance
+-- many walk frames before the next draw, which looks like a slide with no
+-- leg animation (issue #93).
function FixedStep:discardCatchup()
self.accum = 0
+ self.suppressCatchup = true
end
return FixedStep
diff --git a/src/core/Game.lua b/src/core/Game.lua
index 5cb4f8e7..7782cd57 100644
--- a/src/core/Game.lua
+++ b/src/core/Game.lua
@@ -266,6 +266,38 @@ end
-- exactly as the owning state computed it
local function sameZones(_, zones) return zones end
+-- Dim alpha for a BATTLE BG "world" battle anywhere in the stack, or nil.
+-- Same whole-stack rule as fillScaleInStack: a party menu or text box opened
+-- during the battle must not drop the dim for a frame.
+function Game.worldBgBattleDim(stack)
+ for i = #(stack and stack.states or {}), 1, -1 do
+ local state = stack.states[i]
+ if state and state.bgMode and state:bgMode() == "world" then
+ return state.BG_WORLD_DIM or 0.55
+ end
+ end
+ return nil
+end
+
+-- Does anything on the stack want the surface scaled to FILL the window
+-- (aspect preserved, bars on the long axis) rather than sit at the fixed
+-- integer scale?
+--
+-- Asked of the WHOLE stack, not just the top. For BATTLE SIZE "fill" that is
+-- because the party menu, bag and text boxes a battle opens must not snap the
+-- surface back to the fixed scale for a frame; the title screen and intro want
+-- it unconditionally, since neither has a world behind it and neither has any
+-- reason to sit in a small box in the middle of a large window.
+function Game.fillScaleInStack(stack)
+ for i = #(stack and stack.states or {}), 1, -1 do
+ local state = stack.states[i]
+ if state and state.wantsFillScale and state:wantsFillScale() then
+ return true
+ end
+ end
+ return false
+end
+
-- A wide battle owns the surface until it leaves the stack. The party,
-- bag, choice and text states it opens still draw their original 160px UI,
-- but the canvas must not snap to 160px between those states.
@@ -279,6 +311,58 @@ function Game.wideBattleInStack(stack)
return nil
end
+-- Whether a state on the stack composes its own screen and so wants the
+-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
+-- everything else here: the text box and YES/NO a battle puts up are states
+-- of their own sitting above it, and they are exactly the elements that must
+-- stay inside the battle's composition rather than dock to the window.
+-- UI LAYOUT: is edge docking switched on? Only the explicit "dynamic" turns
+-- it on, so a save written before the option existed -- and any caller with no
+-- save at all, which is most of the headless suites -- gets CENTERED, the
+-- behaviour the port shipped with.
+function Game.dynamicUI(save)
+ local options = save and save.options
+ return options ~= nil and options.uiLayout == "dynamic"
+end
+
+function Game.uiAnchorsHeldInStack(stack)
+ for i = #(stack and stack.states or {}), 1, -1 do
+ local state = stack.states[i]
+ if state and state.holdsUIAnchors then return true end
+ end
+ return false
+end
+
+-- Where Game:draw starts drawing this frame. Normally the topmost opaque
+-- state (StateStack:visibleBase) -- but BATTLE BG "world" composes the battle
+-- over the LIVE map, and an opaque state pushed on top of it (the party menu,
+-- the bag) becomes that base, cutting the overworld -- and with it the world
+-- pass -- out of the frame entirely. The backdrop the battle established
+-- then collapses to endFrame's flat black clear for as long as the menu is
+-- up. So a world-bg battle keeps the frame starting from underneath itself
+-- until it leaves the stack, the same hold uiFill and the dim already use.
+--
+-- Only the START of the draw moves. The clear stays keyed to the real
+-- visibleBase, so the menu still gets its opaque canvas and draws exactly as
+-- before; what changes is the window AROUND its letterbox, which keeps
+-- showing the map instead of going black. Both menus fill their own
+-- 160x144 field first, so nothing beneath them shows through it.
+function Game.drawBaseInStack(stack, visibleBase)
+ local states = stack and stack.states or {}
+ for i = visibleBase - 1, 1, -1 do
+ local state = states[i]
+ if state and state.bgMode and state:bgMode() == "world" then
+ -- restart the search from under the battle: the highest opaque state at
+ -- or below it (the overworld), not the menu sitting over it
+ for j = i, 1, -1 do
+ if states[j].isOpaque then return j end
+ end
+ return 1
+ end
+ end
+ return visibleBase
+end
+
-- Shift classic SGB zones to the centred UI. A full-width base zone extends
-- into both margins, keeping the canvas' paper color continuous; narrower
-- sprite and status zones move with the classic UI content.
@@ -304,6 +388,10 @@ function Game:draw()
-- white clear
local base = self.stack:visibleBase()
local worldBelow = self.stack.states[base] == self.overworld
+ -- a world-bg battle keeps the map drawing under whatever it opened, so the
+ -- world pass can run for a frame whose CLEAR is still an opaque menu's
+ local drawFrom = Game.drawBaseInStack(self.stack, base)
+ local worldDrawn = self.stack.states[drawFrom] == self.overworld
-- A wide battle holds its 304px surface through every menu or prompt it
-- opens. States that do not draw the wide battle composition are centred
-- in that surface below, so their classic coordinates and hit testing stay
@@ -320,8 +408,33 @@ function Game:draw()
else
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
end
+ -- BATTLE SIZE: scale the battle surface to the window instead of the
+ -- classic integer letterbox. Read from the whole stack, not just the top,
+ -- so a party menu or text box opened mid-battle keeps the same surface.
+ Renderer.uiFill = Game.fillScaleInStack(self.stack)
+ -- BATTLE BG "world": dim the overworld the battle is drawn over. Read off
+ -- the stack for the same reason as uiFill above -- a prompt opened during
+ -- the battle must not drop the dim for a frame.
+ Renderer.battleDim = Game.worldBgBattleDim(self.stack)
+ -- ...and for the same reason the UI's own scale has to know the world is
+ -- still the backdrop while an opaque menu covers it. Renderer:uiScale
+ -- steps the UI down with the survey zoom only while a world is behind it,
+ -- gated on this frame's world pass -- which the party menu and the bag end
+ -- by being opaque. Without this hold they lose the step-down and blit at
+ -- full fit scale over a battle drawn at the zoomed-out one.
+ Renderer.uiWorldHold = Renderer.battleDim ~= nil
+ -- ...and a battle keeps its dialogue box and YES/NO inside its own screen
+ -- instead of letting them dock to the window edge.
+ -- UI LAYOUT: CENTERED (the default) is a fixed letterbox -- every element
+ -- stays inside the 160x144 canvas and the UI does not follow the survey
+ -- zoom, so the screen furniture never moves or resizes under the player.
+ -- That is the composition the port shipped with. DYNAMIC opts into both
+ -- halves: the dialogue box docks to the window's bottom edge, the START
+ -- menu to its top right, and the whole UI steps down with the zoom.
+ Renderer.uiCentered = not Game.dynamicUI(self.save)
+ Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack)
Renderer:beginFrame(worldBelow)
- for i = self.stack:visibleBase(), #self.stack.states do
+ for i = drawFrom, #self.stack.states do
local state = self.stack.states[i]
local wideState = state and state.isWideBattleLayout
and state:isWideBattleLayout()
@@ -358,7 +471,13 @@ function Game:draw()
if ModRuntime.wantsHook("render.zones") then
zones = ModRuntime.call("render.zones", sameZones, self, zones)
end
- if worldBelow and self.overworld.sgbWorldZones then
+ -- Keyed to whether the map actually DREW, not to whether it is the clear's
+ -- base: an opaque menu over a world-bg battle still renders the world pass
+ -- (drawBaseInStack), and leaving worldZones nil there drops endFrame's
+ -- world blit onto the UI zone list instead -- the party menu's own HP-bar
+ -- palettes, in 160x144 space, smeared across a world-canvas-sized image.
+ -- That is the offset, red-for-green map behind the menu.
+ if worldDrawn and self.overworld.sgbWorldZones then
worldZones = self.overworld:sgbWorldZones()
end
local viewport = Renderer:endFrame(zones, worldZones)
@@ -391,6 +510,24 @@ function Game:wheelmoved(_, dy)
end
end
+function Game:_cycleSpeed(dir)
+ if not (self.save and self.save.options) then return end
+ local busy
+ local ow = self.overworld
+ if ow then
+ local top = self.stack:top()
+ busy = ow.transitioning
+ or (top == ow and (
+ (ow.runner and ow.runner.isRunning and ow.runner:isRunning())
+ or (ow.scriptMoves and #ow.scriptMoves > 0)
+ or ow.engaging or ow.emote))
+ end
+ if busy then return end
+ local GameSpeed = require("src.core.GameSpeed")
+ self.save.options.speed = GameSpeed.cycle(self.save.options.speed, dir)
+ self:writeOptions()
+end
+
function Game:keypressed(key)
if self.stack and self.stack:top() and self.stack:top().onKeyPressed then
self.stack:top():onKeyPressed(key)
@@ -428,6 +565,11 @@ function Game:keypressed(key)
elseif key == "=" then
self:zoomStep(1)
return
+ elseif key == "1" then
+ -- cycle GAME SPEED (0.25X → 200X, logic only; audio unaffected);
+ -- R2/L2 on gamepad do the same (see gamepadpressed)
+ self:_cycleSpeed(1)
+ return
elseif key == "2" then
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change
-- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so
@@ -508,6 +650,27 @@ function Game:gamepadpressed(joystick, button)
-- a controller is being used: the touch overlay steps aside until the
-- next screen touch (mobile only; a no-op elsewhere)
TouchControls:noteGamepad()
+ -- Select held? Needed both to suppress shoulder speed hotkeys (Select+L
+ -- is a display chord on NX / VoxelMod) and for the chord path below.
+ local selectHeld = Input:isDown("select")
+ if not selectHeld and joystick and joystick.isGamepadDown then
+ local ok, down = pcall(function()
+ return joystick:isGamepadDown("back")
+ end)
+ selectHeld = ok and down == true
+ end
+ -- shoulder buttons cycle GAME SPEED (R2/rightshoulder = faster,
+ -- L2/leftshoulder = slower; same as keyboard hotkey 1). Skip while
+ -- Select is held so Select+L can reach displayChordDigit ("7").
+ if not selectHeld then
+ if button == "rightshoulder" then
+ self:_cycleSpeed(1)
+ return
+ elseif button == "leftshoulder" then
+ self:_cycleSpeed(-1)
+ return
+ end
+ end
-- BindingsMenu's pad capture rides the same top-state routing as keys
local top = self.stack and self.stack:top()
if top and top.onGamepadPressed then
@@ -517,13 +680,6 @@ function Game:gamepadpressed(joystick, button)
-- Select+face display chords → same digit path as Game:keypressed
-- (COLORS/TILT/pipelines). Intercept before Input so face does not
-- also fire GB A/B. Dual-path: raw already ignored when isGamepad().
- local selectHeld = Input:isDown("select")
- if not selectHeld and joystick and joystick.isGamepadDown then
- local ok, down = pcall(function()
- return joystick:isGamepadDown("back")
- end)
- selectHeld = ok and down == true
- end
if selectHeld then
local digit = GamepadMap.displayChordDigit(button)
if digit then
@@ -558,15 +714,38 @@ local function isAccelerometer(joystick)
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
end
+-- BindingsMenu's raw-stick capture rides the same top-state routing as the
+-- keyboard and gamepad paths (#632). Only a stick SDL does not recognize
+-- as a gamepad reaches the capture: a recognized pad raises BOTH
+-- joystickpressed and gamepadpressed for one press, and the joystick half
+-- would otherwise beat its own gamepadpressed to the armed row and record
+-- "JOY1" for a button the player can plainly see is A. Same predicate as
+-- Input's, kept local here so Game never reaches into Input's internals.
+local function isRawStick(joystick)
+ return not (joystick and joystick.isGamepad and joystick:isGamepad())
+end
+
function Game:joystickpressed(joystick, button)
if isAccelerometer(joystick) then return end
TouchControls:noteGamepad()
+ local top = self.stack and self.stack:top()
+ if isRawStick(joystick) and top and top.onJoystickPressed then
+ top:onJoystickPressed(button)
+ return
+ end
Input:joystickpressed(joystick, button)
end
function Game:joystickreleased(joystick, button)
if isAccelerometer(joystick) then return end
+ -- same observe-after-Input contract as Game:keyreleased (#589): the
+ -- capture watches the release, it never owns it, so a held-state flag
+ -- Input saw go down before the capture armed cannot be stranded
Input:joystickreleased(joystick, button)
+ local top = self.stack and self.stack:top()
+ if isRawStick(joystick) and top and top.onJoystickReleased then
+ top:onJoystickReleased(button)
+ end
end
function Game:joystickaxis(joystick, axis, value)
@@ -712,6 +891,9 @@ function Game:applyOptions(opts)
-- returns true when a persisted GBC FX level was cleared on mobile
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts)
+ -- after VideoMode: a faithful-resolution lock is an exact window size, so
+ -- it has to be the last word on the window (it drops fullscreen to hold)
+ require("src.core.FaithfulRes").applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
diff --git a/src/core/GameSpeed.lua b/src/core/GameSpeed.lua
index 85b81b3c..0a5ddde3 100644
--- a/src/core/GameSpeed.lua
+++ b/src/core/GameSpeed.lua
@@ -18,7 +18,7 @@ local GameSpeed = {}
-- attempt is long enough that the iteration loop, not the engine, is the
-- bottleneck. Vsync caps how much a real frame can do, so past 10X the
-- multiplier is increasingly a ceiling rather than a rate.
-GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75, 100,200 }
+GameSpeed.LEVELS = { 1, 2, 3, 4, 10, 20, 30, 50, 75, 100, 200 }
GameSpeed.DEFAULT = 1
function GameSpeed.levelLabel(v)
diff --git a/src/core/GamepadMap.lua b/src/core/GamepadMap.lua
index 0f713845..c76a1504 100644
--- a/src/core/GamepadMap.lua
+++ b/src/core/GamepadMap.lua
@@ -20,6 +20,11 @@ GamepadMap.NX_GAMEPAD_BINDINGS = {
}
-- Generic SDL joysticks without a game-controller DB entry (Linux handhelds).
+-- Desktop XInput order only: raw numbering is per-driver, and SDL's iOS/MFi
+-- driver packs only the buttons a pad reports, which slides the D-pad onto
+-- 7..10 (#620). These are defaults; Input:applyBindings layers "joyN" pad
+-- rebinds over them (#632). Only sticks SDL does NOT recognize as gamepads
+-- are served from this table (see GamepadMap.ignoreRawForJoystick).
GamepadMap.RAW_BUTTON_BINDINGS = {
[1] = "a", [2] = "b",
[7] = "select", [8] = "start", [9] = "select", [10] = "start",
@@ -63,6 +68,12 @@ function GamepadMap.gamepadBindings()
return GamepadMap.DEFAULT_GAMEPAD_BINDINGS
end
+-- Whole raw-index table for Input:applyBindings joyBindings seeding (#632).
+function GamepadMap.rawBindings()
+ if nxActive() then return GamepadMap.NX_RAW_BUTTON_BINDINGS end
+ return GamepadMap.RAW_BUTTON_BINDINGS
+end
+
function GamepadMap.mapGamepadButton(button)
return GamepadMap.gamepadBindings()[button]
end
diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua
index a6e3559e..32850e64 100644
--- a/src/core/HostShell.lua
+++ b/src/core/HostShell.lua
@@ -13,6 +13,55 @@ function HostShell.envPrefix()
return ""
end
+-- Windows: every host tool we shell out to (curl for the update and mod-index
+-- fetches, the PowerShell ROM picker, the update downloader's `start /b`) is
+-- spawned through io.popen / os.execute, which run it under cmd.exe. A
+-- GUI-subsystem process owns no console, so each of those children allocates
+-- its own -- one console window flashing per call, several stacking up during
+-- a mod install or an update (#606). #74 fixed the same storm for the
+-- per-file cache mkdir by dropping the shell entirely (src/import/CacheFs.lua);
+-- the callers above genuinely need one, so we do the other half: allocate a
+-- single console for ourselves, once, and hide it. A child inherits the
+-- parent's console when the parent has one, so every later spawn attaches to
+-- that invisible console and pops up nothing. GUI dialogs the children raise
+-- (the PowerShell OpenFileDialog) are desktop windows and still appear.
+--
+-- Skipped when a console already exists, which is the developer case
+-- (lovec.exe, what scripts/run.ps1 prefers, or t.console), so printed output
+-- keeps landing in the terminal the game was launched from. POKEPORT_CONSOLE=1
+-- opts out entirely and restores the old behaviour. Memoized; non-Windows and
+-- FFI-less builds no-op. Called once from love.load before anything shells
+-- out (main.lua).
+local consoleHidden = nil
+
+function HostShell.hideHostConsole()
+ if consoleHidden ~= nil then return consoleHidden end
+ consoleHidden = false
+ if os.getenv("POKEPORT_CONSOLE") == "1" then return consoleHidden end
+
+ local okFfi, ffi = pcall(require, "ffi")
+ if not okFfi or ffi.os ~= "Windows" then return consoleHidden end
+
+ -- kernel32 (AllocConsole/GetConsoleWindow) and user32 (ShowWindow) are
+ -- already loaded in any LOVE process, so ffi.C resolves both -- the same
+ -- assumption CacheFs makes for CreateDirectoryA.
+ pcall(ffi.cdef, [[
+ void *GetConsoleWindow(void);
+ int AllocConsole(void);
+ int ShowWindow(void *hWnd, int nCmdShow);
+ ]])
+ local ok, hidden = pcall(function()
+ if ffi.C.GetConsoleWindow() ~= nil then return false end
+ if ffi.C.AllocConsole() == 0 then return false end
+ local hwnd = ffi.C.GetConsoleWindow()
+ if hwnd == nil then return false end
+ ffi.C.ShowWindow(hwnd, 0) -- SW_HIDE
+ return true
+ end)
+ consoleHidden = (ok and hidden) or false
+ return consoleHidden
+end
+
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
function HostShell.popen(command, mode)
local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
@@ -69,4 +118,121 @@ function HostShell.restart()
ffi.C.execv(appimage, ffi.cast("char *const *", argv))
end
+-- ------- HTTP transport ----------------------------------------------------
+--
+-- Every remote fetch (mod index, mod releases, thumbnails) used to shell out
+-- to curl, which macOS / Windows 10+ / desktop Linux all ship and Android does
+-- not: adding a mod index on Android died with "curl is not available on this
+-- platform" (#597). Android goes through the GameActivity.httpDownload JNI
+-- bridge instead (HttpsURLConnection, using the INTERNET permission link play
+-- already needs), surfaced by our vendored liblove as
+-- love.system.httpDownload(url, absPath, userAgent, accept). Both transports
+-- block the calling thread and deal in whole files, so callers keep exactly
+-- the contract they had with curl.
+
+-- Shell quoting for one curl argument; cmd.exe has no single-quote form.
+function HostShell.quote(s)
+ s = tostring(s)
+ if love and love.system and love.system.getOS
+ and love.system.getOS() == "Windows" then
+ return '"' .. s:gsub('"', '') .. '"'
+ end
+ return "'" .. s:gsub("'", "'\\''") .. "'"
+end
+
+function HostShell.haveCurl()
+ local pipe = HostShell.popen("curl --version")
+ if not pipe then return false end
+ local readOk, out = pcall(function() return pipe:read("*a") end)
+ pcall(function() pipe:close() end)
+ return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
+end
+
+-- The bridge only exists in our Android liblove. An older APK reports nil
+-- here and falls back to the "no transport" error the callers already show;
+-- the iOS build compiles the same wrapper but always returns false, so gate
+-- on the OS as well and keep its error message honest.
+local function haveBridge()
+ if not (love and love.system and type(love.system.httpDownload) == "function") then
+ return false
+ end
+ return love.system.getOS and love.system.getOS() == "Android"
+end
+
+-- Is any transport available at all? Callers gate on this, never on curl.
+function HostShell.canFetch()
+ return HostShell.haveCurl() or haveBridge()
+end
+
+-- Download url to an absolute host path. Returns true, or nil plus an error.
+-- The curl branch deliberately ignores curl's exit code, as the download paths
+-- always did: callers judge the result by the file they got.
+function HostShell.httpDownload(url, absPath, userAgent, accept)
+ if type(url) ~= "string" or url == "" then return nil, "missing url" end
+ if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
+ userAgent = userAgent or "gen1recomp"
+ if HostShell.haveCurl() then
+ local cmd = "curl -fsSL --connect-timeout 15 --max-time 300 "
+ .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
+ if accept then
+ cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
+ end
+ cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " " .. HostShell.quote(url)
+ local pipe = HostShell.popen(cmd)
+ if not pipe then return nil, "could not start download" end
+ pcall(function() pipe:read("*a") end)
+ pcall(function() pipe:close() end)
+ return true
+ end
+ if not haveBridge() then
+ return nil, "no network transport on this platform"
+ end
+ local ok, done = pcall(love.system.httpDownload, url, absPath, userAgent, accept)
+ if ok and done then return true end
+ return nil, "download failed"
+end
+
+-- GET returning the body. curl streams it through a pipe; the Android bridge
+-- can only write a file, so there we fetch into the save directory (the only
+-- writable root on Android) and read it back.
+function HostShell.httpGet(url, userAgent, accept)
+ if type(url) ~= "string" or url == "" then return nil, "missing url" end
+ userAgent = userAgent or "gen1recomp"
+ if HostShell.haveCurl() then
+ local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
+ .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
+ if accept then
+ cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
+ end
+ cmd = cmd .. HostShell.quote(url)
+ local pipe = HostShell.popen(cmd)
+ if not pipe then return nil, "could not run curl" end
+ local readOk, out = pcall(function() return pipe:read("*a") end)
+ pcall(function() pipe:close() end)
+ if not readOk then return nil, "fetch failed: " .. tostring(out) end
+ if not out or out == "" then return nil, "empty response from " .. url end
+ return out
+ end
+ if not haveBridge() then
+ return nil, "no network transport on this platform"
+ end
+ if not (love.filesystem and love.filesystem.getSaveDirectory) then
+ return nil, "fetch needs LOVE"
+ end
+ local dirOk, saveDir = pcall(love.filesystem.getSaveDirectory)
+ if not dirOk or not saveDir or saveDir == "" then
+ return nil, "no save directory"
+ end
+ local name = "http_fetch.tmp"
+ pcall(love.filesystem.remove, name)
+ local ok, err = HostShell.httpDownload(url, saveDir .. "/" .. name, userAgent, accept)
+ if not ok then return nil, err end
+ local readOk, body = pcall(love.filesystem.read, name)
+ pcall(love.filesystem.remove, name)
+ if not readOk or type(body) ~= "string" or body == "" then
+ return nil, "empty response from " .. url
+ end
+ return body
+end
+
return HostShell
diff --git a/src/core/Input.lua b/src/core/Input.lua
index cc35a3d9..b450994d 100644
--- a/src/core/Input.lua
+++ b/src/core/Input.lua
@@ -30,6 +30,9 @@ local DEFAULT_BINDINGS = {
local STICK_ON = 0.5
local STICK_OFF = 0.3
+-- Raw joystick defaults + NX overrides live in src/core/GamepadMap.lua
+-- (see RAW_BUTTON_BINDINGS / NX_RAW_BUTTON_BINDINGS and #620 / #632).
+
local HAT_DIRECTIONS = {
u = { "up" }, d = { "down" }, l = { "left" }, r = { "right" },
lu = { "left", "up" }, ru = { "right", "up" },
@@ -49,11 +52,17 @@ end
-- BindingsMenu:storeBinding) -- without this the menu records a choice
-- that never actually reaches gameplay.
function Input:applyBindings(overlay)
- local keys, pads = {}, {}
+ local keys, pads, joys = {}, {}, {}
for key, action in pairs(DEFAULT_BINDINGS) do keys[key] = action end
for button, action in pairs(GamepadMap.gamepadBindings()) do
pads[button] = action
end
+ -- Seed raw defaults from GamepadMap (desktop XInput order or NX OLED
+ -- indices) so joyN rebinds (#632) and dual-path guards (#620) share one
+ -- table with the Switch face-label remap.
+ for index, action in pairs(GamepadMap.rawBindings()) do
+ joys[index] = action
+ end
for actionId, binding in pairs(overlay or {}) do
if type(binding) == "table" then
if binding.key then keys[binding.key] = actionId end
@@ -62,8 +71,20 @@ function Input:applyBindings(overlay)
keys[binding] = actionId
end
end
+ -- A pad binding named "joyN" is the Nth button of a stick SDL has no
+ -- game-controller-database entry for, captured on the joystick path by
+ -- src/ui/BindingsMenu.lua (#632). It deliberately rides the existing
+ -- pad slot: the CONTROLS row, the swap in BindingsMenu:storeBinding and
+ -- START's reset-all then all stay one code path, and this loop is the
+ -- only place that has to know what the name means. Laid over the raw
+ -- defaults AFTER them, so a rebind wins the button it claims.
+ for padName, action in pairs(pads) do
+ local n = tonumber(padName:match("^joy(%d+)$"))
+ if n then joys[n] = action end
+ end
self.keyBindings = keys
self.padBindings = pads
+ self.joyBindings = joys
end
-- Purely event-driven state (press sets true, release sets false) has no
@@ -179,15 +200,28 @@ function Input:gamepadreleased(joystick, button)
end
end
+-- LOVE raises love.joystickpressed for EVERY stick, including ones SDL
+-- recognizes as gamepads, which raise love.gamepadpressed for the same
+-- physical press as well. Answering both meant the fixed raw table
+-- re-asserted the factory A/B/START/SELECT map underneath the player's
+-- rebinds, so swapping A and B in CONTROLS pressed both at once and any
+-- controller rebind of those four looked ignored; on iOS the MFi driver's
+-- packing put the D-pad on 7..10, so a D-pad press also fired SELECT or
+-- START (#620, #632). A recognized pad is served by the gamepad path
+-- alone; the raw path exists for sticks with no game-controller-database
+-- entry. A nil joystick is a raw stick: that is how
+-- tests/input_hold_test.lua and the drivers drive this path.
+-- Gate: GamepadMap.ignoreRawForJoystick (pcall-safe isGamepad check).
+
function Input:joystickpressed(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
- local btn = GamepadMap.mapRawButton(button)
+ local btn = self.joyBindings[button]
if btn then press(self, btn, "joy:" .. button) end
end
function Input:joystickreleased(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
- local btn = GamepadMap.mapRawButton(button)
+ local btn = self.joyBindings[button]
if btn then release(self, btn, "joy:" .. button) end
end
@@ -227,6 +261,7 @@ function Input:gamepadaxis(joystick, axis, value)
end
function Input:joystickaxis(joystick, axis, value)
+ if GamepadMap.ignoreRawForJoystick(joystick) then return end
if axis == 1 then
self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then
@@ -234,7 +269,12 @@ function Input:joystickaxis(joystick, axis, value)
end
end
+-- Same duplicate-event rule as joystickpressed (#620, #632): a recognized
+-- pad's D-pad already arrived as dpup/dpdown/dpleft/dpright through the
+-- gamepad map, so letting the hat answer too would re-assert the factory
+-- directions on top of a direction rebind.
function Input:joystickhat(joystick, hat, direction)
+ if GamepadMap.ignoreRawForJoystick(joystick) then return end
local source = "hat:" .. hat
for _, btn in ipairs(self.hatDirs[hat] or {}) do
release(self, btn, source)
diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua
index cc730b58..6ce20c58 100644
--- a/src/core/SaveData.lua
+++ b/src/core/SaveData.lua
@@ -219,6 +219,24 @@ function SaveData.defaultOptions()
-- battle screen composition: og (the 160x144 original) | wide
-- (304x144, src/battle/WideBattle.lua)
battleLayout = "og",
+ -- BATTLE SIZE: "fixed" = the classic integer-scaled letterbox; "fill" =
+ -- scale the battle surface to the window so it fills vertically. See
+ -- BattleState:wantsFillScale.
+ battleFit = "fixed",
+ -- BATTLE BG: what fills the screen behind and around the battle.
+ -- "white" = the display mode's paper shade (the classic look),
+ -- "black" = plain black bars, "world" = the frozen overworld showing
+ -- through, dimmed. See BattleState:bgMode.
+ battleBg = "white",
+ -- UI LAYOUT: "centered" = a fixed letterbox. Every element sits where it
+ -- was drawn in the 160x144 canvas and the UI does not follow the survey
+ -- zoom, so nothing moves or resizes under the player. The original
+ -- composition. "dynamic" = the dialogue box docks to the window's bottom
+ -- edge, the START menu to its top right, and the UI steps down with the
+ -- zoom. Centered is the default: dynamic reads better zoomed out, but it
+ -- moves the screen furniture, so it is opt-in.
+ -- See Game.dynamicUI, Renderer:setUIAnchor and Renderer:uiScale.
+ uiLayout = "centered",
ruleset = "gen1_faithful",
-- 0-7 like the GB's NR50 master volume
musicVol = 7,
@@ -239,6 +257,9 @@ function SaveData.defaultOptions()
voidFill = "trees",
-- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed",
+ -- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
+ -- src/core/FaithfulRes.lua. Ignored on mobile.
+ faithfulRes = 0,
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- graphics performance tier: auto | high | balanced | low. "auto"
@@ -254,6 +275,15 @@ function SaveData.defaultOptions()
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
+ -- Named setups the player can switch between (#593; src/mods/ModProfile.lua
+ -- owns the shape, src/mods/ManagerState.lua the UI): each row is
+ -- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }.
+ -- activeProfile names the row the live set currently matches (nil for
+ -- ad-hoc, so it has no default entry here; mergeOptions preserves it).
+ -- modProfilesSeeded records that the pre-profiles setup was already
+ -- migrated into PROFILE 1, so deleting every profile does not re-seed one.
+ modProfiles = {},
+ modProfilesSeeded = false,
-- GitHub release checks for mods with a manifest "github" field
-- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours.
modUpdateCache = {},
@@ -269,8 +299,11 @@ function SaveData.defaultOptions()
modIndexCache = {},
-- On-screen touch overlay (Android/iOS; see src/core/TouchControls.lua).
-- enabled=false hides it permanently (distinct from auto-hide-on-gamepad).
- -- positions are optional normalized centers {x=0..1, y=0..1} per control
- -- (dpad/a/b/start/select); nil means the default layout.
+ -- layouts.portrait / layouts.landscape each hold optional normalized
+ -- centers {x=0..1, y=0..1} per control (dpad/a/b/start/select) plus a
+ -- size scale; nil positions mean that orientation draws the default
+ -- layout (#633). Pre-#633 files stored one top-level positions table;
+ -- TouchControls.normalizeConfig folds it into both orientations on load.
touchControls = { enabled = true },
}
end
diff --git a/src/core/Sound.lua b/src/core/Sound.lua
index d1bd9d05..2c5deb14 100644
--- a/src/core/Sound.lua
+++ b/src/core/Sound.lua
@@ -82,6 +82,40 @@ local function isChipDef(def)
return type(def) == "table" and (def.chip ~= nil or def.address ~= nil)
end
+-- OpenAL only spatializes 1-channel Sources, and one left at the default
+-- (0,0,0) position sits on top of the listener, which OpenAL renders as an
+-- ambient sound spread over every output channel the device has: on an
+-- interface with more than two outputs the SFX also came out of outputs 5+6
+-- while the 2-channel music stayed on 1+2 (#626). A Source cannot change its
+-- channel count after the fact, so a mono file def is re-decoded and its
+-- sample duplicated into a stereo buffer, which OpenAL never spatializes.
+-- Chip SFX and cries are already stereo at the source (ChipSynth
+-- renderEffectData); this covers file defs, i.e. Yellow's 8-bit mono PCM
+-- Pikachu clips (RomExtractor extractPikachuCries) and mod-supplied wav/ogg
+-- SFX. Every step is guarded: a headless love stub without love.sound, or a
+-- decoder that will not hand back SoundData, keeps the original Source.
+local function widenMono(source, file)
+ if not (source and love.sound and love.sound.newSoundData) then
+ return source
+ end
+ local ok, channels = pcall(function() return source:getChannelCount() end)
+ if not ok or channels ~= 1 then return source end
+ local built, widened = pcall(function()
+ local mono = love.sound.newSoundData(file)
+ local frames = mono:getSampleCount()
+ local stereo = love.sound.newSoundData(frames, mono:getSampleRate(),
+ mono:getBitDepth(), 2)
+ for index = 0, frames - 1 do
+ local value = mono:getSample(index)
+ stereo:setSample(index, 1, value)
+ stereo:setSample(index, 2, value)
+ end
+ return love.audio.newSource(stereo, "static")
+ end)
+ if built and widened then return widened end
+ return source
+end
+
-- a file def carries an optional playback rate; a bare string is shorthand
-- for { file = }
local function newFileSource(def)
@@ -89,6 +123,7 @@ local function newFileSource(def)
if type(file) ~= "string" then return nil, "no chip program and no file" end
local ok, s = pcall(love.audio.newSource, file, "static")
if not ok or not s then return nil, ok and "no source" or tostring(s) end
+ s = widenMono(s, file) -- keep mono defs off the surround channels (#626)
if type(def) == "table" and def.pitch then pcall(s.setPitch, s, def.pitch) end
return s
end
@@ -233,12 +268,16 @@ function Sound.playPikaCry(data, n)
local src = cache[key]
if src == false then return nil end
if not src then
- local ok, s = pcall(love.audio.newSource,
- ("assets/generated/audio/pika_cries/cry_%02d.wav"):format(n), "static")
- if not ok then
+ local path = ("assets/generated/audio/pika_cries/cry_%02d.wav"):format(n)
+ local ok, s = pcall(love.audio.newSource, path, "static")
+ if not ok or not s then
cache[key] = false
return nil
end
+ -- the importer writes these clips as 8-bit mono (RomExtractor
+ -- extractPikachuCries), so they need the same widening as the chip
+ -- effects to stay off a multi-output device's surround channels (#626)
+ s = widenMono(s, path)
s:setVolume(volumeFor(key))
cache[key] = s
src = s
diff --git a/src/core/Timing.lua b/src/core/Timing.lua
new file mode 100644
index 00000000..1a1e05bf
--- /dev/null
+++ b/src/core/Timing.lua
@@ -0,0 +1,198 @@
+-- Hardware frame budgets, in fixed 60Hz logic steps.
+--
+-- The original spends a large share of its running time inside DelayFrames
+-- calls that produce no visible change -- the pause after a page break, the
+-- beat before a status move resolves, the one-HP-at-a-time drain of an HP
+-- bar. Porting the visible half of a sequence and dropping the wait is what
+-- makes a port read as snappier than hardware, so every one of those waits
+-- lives here with its asm citation instead of as a file-local constant.
+--
+-- See docs/timing-parity.md for the full catalog and the measurement method;
+-- tools/scan_pokered_delays.ps1 regenerates the hardware side from a
+-- disassembly checkout.
+
+local Timing = {}
+
+-- home/palettes.asm:14 -- three frames to let the bg map fully update
+Timing.DELAY3 = 3
+
+-- home/fade.asm: each fade is a loop of `ld c, 8 / call DelayFrames`
+Timing.FADE_IN_FROM_BLACK = 32 -- fade.asm:21, b = 4
+Timing.FADE_OUT_TO_BLACK = 32 -- fade.asm:43, b = 4
+Timing.FADE_OUT_TO_WHITE = 24 -- fade.asm:26, b = 3
+Timing.FADE_IN_FROM_WHITE = 24 -- fade.asm:48, b = 3
+
+-- Overworld -----------------------------------------------------------------
+
+-- home/overworld.asm:703 PlayMapChangeSound tail-calls GBFadeOutToBlack on
+-- every map change. There is no matching fade in: the new map is drawn while
+-- the palettes are still blacked out and LoadGBPal restores them in one write,
+-- so the map appears instantly.
+Timing.WARP_FADE_OUT = Timing.FADE_OUT_TO_BLACK
+Timing.WARP_FADE_IN = 0
+
+-- home/overworld.asm:351-352 -- after a battle, before EnterMap
+Timing.POST_BATTLE_RETURN = 10
+
+-- engine/overworld/player_animations.asm:5-7 -- EnterMapAnim, the fly /
+-- teleport / dungeon-warp arrival: Delay3 then GBFadeInFromWhite
+Timing.SPECIAL_WARP_ENTRY = Timing.DELAY3 + Timing.FADE_IN_FROM_WHITE
+-- player_animations.asm:43 -- dungeon warp holds before handing back control
+Timing.DUNGEON_WARP_ARRIVAL = 50
+
+-- Text ----------------------------------------------------------------------
+
+-- home/text.asm:283-307 ScrollTextUpOneLine is `ld b, 5` of DelayFrame, and
+-- its own comment notes it is "always called twice in a row"
+Timing.TEXT_SCROLL_LINE = 5
+Timing.TEXT_SCROLL_PAIR = Timing.TEXT_SCROLL_LINE * 2
+
+-- Both _ContText (home/text.asm:262-277) and Paragraph (:230-243) print the
+-- â–¼ and call ProtectedDelay3 *before* ManualTextScroll starts watching the
+-- joypad, so three frames pass with the arrow up and the button ignored.
+Timing.TEXT_PRE_ADVANCE = Timing.DELAY3
+
+-- Paragraph / PageChar clear the box and then hold (home/text.asm:239-240,
+-- :254-255) before the next page starts typing.
+Timing.TEXT_PAGE_CLEAR = 20
+
+-- Totals, for the catalog and the parity tests.
+Timing.TEXT_CONT = Timing.TEXT_PRE_ADVANCE + Timing.TEXT_SCROLL_PAIR
+Timing.TEXT_PARAGRAPH = Timing.TEXT_PRE_ADVANCE + Timing.TEXT_PAGE_CLEAR
+Timing.TEXT_PAGE = Timing.TEXT_PARAGRAPH
+
+Timing.TEXT_PAUSE = 30 -- home/text.asm:500 TextCommand_PAUSE
+Timing.TEXT_DOT = 10 -- home/text.asm:576 TextCommand_DOTS, per dot
+
+-- Menus ---------------------------------------------------------------------
+
+-- engine/menus/text_box.asm:322-323 / :333-334 -- both branches of a
+-- two-option (yes/no) menu hold before restoring the screen tiles
+Timing.YES_NO_ANSWER = 15
+
+Timing.LIST_MENU_OPEN = 10 -- home/list_menu.asm:55-56
+Timing.LIST_MENU_REDRAW = Timing.DELAY3 -- home/list_menu.asm:64
+
+-- engine/menus/start_sub_menus.asm:224-225
+Timing.FIELD_TELEPORT = 60 + Timing.DELAY3
+
+-- Battle --------------------------------------------------------------------
+
+-- SlidePlayerAndEnemySilhouettesOnScreen (engine/battle/core.asm:9-49):
+-- the enemy comes in on BG SCX $90 -> $00 and the player's back pic on
+-- decrementing OAM x, both 2 px per frame -- so 144 px over 72 frames. The
+-- port ran 160 px at 4 px/frame (40 frames), a little under twice too fast.
+Timing.BATTLE_SLIDE_IN_FRAMES = 72
+Timing.BATTLE_SLIDE_PX_PER_FRAME = 2
+
+-- PrintBeginningBattleText .trainerBattle (engine/battle/common_text.asm):
+-- SFX_SILPH_SCOPE plays into a clear window (PlaySound then
+-- WaitForSoundToFinish, which blocks), and only after `ld c, 20 /
+-- DelayFrames` do DrawAllPokeballs and the "wants to fight!" text run.
+Timing.TRAINER_INTRO_SFX_GAP = 20
+
+Timing.BATTLE_START_SENDOUT = 40 -- engine/battle/core.asm:155-156
+Timing.MOVE_ANIM_PRE = Timing.DELAY3 -- core.asm:6638 PlayMoveAnimation
+
+-- core.asm:3185-3186 (player) / :5587-5588 (enemy). Reached when the move
+-- has 0 BP (core.asm:3145 -- every status move) or missed (:3158), so this
+-- beat is paid on a large fraction of all turns.
+Timing.MOVE_STATUS_OR_MISS = 30
+
+-- PlayApplyingAttackAnimation's six types (AnimationTypePointerTable,
+-- engine/battle/animations.asm:490-524). The two shake families are
+-- `AnimationShakeScreenHorizontallySlow`, whose double push/pop makes each
+-- outer pass cost 4b frames and run c times -- so c * 4b.
+Timing.SHAKE_VERTICAL = 48 -- type 1, b=8: 8 x 6
+Timing.SHAKE_HORIZ_HEAVY = 72 -- type 2, b=8: 8 x 9
+Timing.SHAKE_HORIZ_SLOW = 48 -- type 3, lb bc, 6, 2: 2 x 4x6
+Timing.SHAKE_HORIZ_LIGHT = 18 -- type 5, b=2: 2 x 9
+Timing.SHAKE_HORIZ_SLOW2 = 24 -- type 6, lb bc, 3, 2: 2 x 4x3
+
+-- Type 4 -- the player's damaging move with no added effect, and so the
+-- single most common animation in the game -- is AnimationBlinkMon
+-- (animations.asm:1360-1376): `ld c, 6` iterations of hide + DelayFrames 5
+-- + show + DelayFrames 5. The asm's own comment calls it "a second or
+-- two"; the port ran it in 20 frames, three times too fast, which is a
+-- large part of why trading blows felt hurried.
+Timing.BLINK_MON = 60
+
+-- SlideDownFaintedMonPic (engine/battle/core.asm:1181-1222): b = PIC_HEIGHT
+-- (7) outer iterations, each closing with `ld c, 2 / call DelayFrames`.
+-- This one the port ran SLOWER than hardware, at 30.
+Timing.FAINT_SLIDE = 14
+
+Timing.RESIDUAL_TICK = 20 -- core.asm:529-530 poison/burn/leech seed
+Timing.CRIT_OHKO_TEXT = 20 -- core.asm:3813-3814
+Timing.SWITCH_PLAYER_MON = 50 -- core.asm:2421-2422
+Timing.NO_MOVES_LEFT = 60 -- core.asm:2753-2754
+Timing.TRAINER_VICTORY = 40 -- core.asm:940-941
+Timing.PLAYER_BLACKOUT = 40 -- core.asm:1143-1144
+Timing.FAINT_SLIDE_ROW = 2 -- core.asm:1216-1217, per row
+Timing.FAINT_SLIDE_STEP = 8 / Timing.FAINT_SLIDE_ROW -- 4px per frame at 1x
+Timing.TRAINER_SLIDE_COL = 2 -- core.asm:1267-1268, per column
+
+-- HP bar (engine/gfx/hp_bar.asm) ---------------------------------------------
+--
+-- UpdateHPBar steps ONE HP point per loop iteration (:81-120). Each
+-- iteration pays:
+-- * 1 frame in UpdateHPBar_PrintHPNumber's DelayFrame (:234) -- but only
+-- when wHPBarType is nonzero (:207-209), i.e. the player's own HUD and
+-- the party menu, never the enemy HUD; and
+-- * 2 frames per pixel the bar actually moved, from
+-- UpdateHPBar_AnimateHPBar's `ld c, 2 / call DelayFrames` (:147-148).
+-- The drain closes with one more pixel step and a Delay3 (:133-135).
+--
+-- So a player-side drain of D HP across P pixels costs D + 2P + 6 frames,
+-- while the same drain on the enemy HUD costs only 2P + 5. A 150 HP mon
+-- losing everything takes 150 + 96 + 6 = 252 frames on hardware.
+
+Timing.HP_BAR_PIXELS = 48 -- the bar is 48 px wide (GetHPBarLength)
+Timing.HP_BAR_PIXEL_STEP = 2 -- frames per pixel of bar movement
+Timing.HP_BAR_HP_STEP = 1 -- frames per HP point, player-side HUD only
+
+-- Pixels the bar shows for `hp` out of `maxHP`. GetHPBarLength floors the
+-- 48ths and clamps the result to at least 1 for any nonzero HP
+-- (engine/gfx/hp_bar.asm:42-45); an empty bar is 0.
+function Timing.hpBarPixels(hp, maxHP)
+ if not maxHP or maxHP <= 0 then return 0 end
+ if hp <= 0 then return 0 end
+ local px = math.floor(hp * Timing.HP_BAR_PIXELS / maxHP)
+ if px < 1 then px = 1 end
+ return px
+end
+
+-- Frames one single-HP step of the drain costs: the per-HP number print
+-- (player side only) plus two frames for every pixel that step moved.
+function Timing.hpDrainStepFrames(fromHP, toHP, maxHP, playerSide)
+ local pixels = math.abs(Timing.hpBarPixels(toHP, maxHP)
+ - Timing.hpBarPixels(fromHP, maxHP))
+ local frames = pixels * Timing.HP_BAR_PIXEL_STEP
+ if playerSide then frames = frames + Timing.HP_BAR_HP_STEP end
+ return frames
+end
+
+-- After the loop, .animateHPBarDone prints the number one last time, runs
+-- AnimateHPBar for a single pixel and falls into Delay3 (hp_bar.asm:132-135)
+-- -- so the tail costs 6 frames on the player's HUD and 5 on the enemy's.
+function Timing.hpDrainClosingFrames(playerSide)
+ local frames = Timing.HP_BAR_PIXEL_STEP + Timing.DELAY3
+ if playerSide then frames = frames + Timing.HP_BAR_HP_STEP end
+ return frames
+end
+
+-- Total cost of draining `fromHP` to `toHP`, for tests and for anything that
+-- needs to budget the whole animation up front.
+function Timing.hpDrainFrames(fromHP, toHP, maxHP, playerSide)
+ local total = 0
+ local hp = fromHP
+ local dir = (toHP < fromHP) and -1 or 1
+ while hp ~= toHP do
+ local nextHP = hp + dir
+ total = total + Timing.hpDrainStepFrames(hp, nextHP, maxHP, playerSide)
+ hp = nextHP
+ end
+ return total + Timing.hpDrainClosingFrames(playerSide)
+end
+
+return Timing
diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua
index 8678dce8..4a6dae2c 100644
--- a/src/core/TouchControls.lua
+++ b/src/core/TouchControls.lua
@@ -14,8 +14,12 @@
--
-- Player preferences (options.touchControls) can permanently disable the
-- overlay and/or override per-control positions as normalized window
--- fractions. The launcher editor (src/ui/TouchControlsEditor.lua) writes
--- those; applyOptions reads them at boot and whenever options change.
+-- fractions. Positions and a size multiplier are stored per orientation
+-- (#633): options.touchControls.layouts.portrait / .landscape, picked from
+-- the safe rect's aspect, so laying the pad out in landscape never moves
+-- the portrait one. The launcher editor (src/ui/TouchControlsEditor.lua)
+-- writes those; applyOptions reads them at boot and whenever options
+-- change.
--
-- Controls press GB buttons through Input:overlayPressed/Released -- their
-- own input source, not a keyboard alias -- so a held overlay direction
@@ -47,6 +51,15 @@ local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 }
local BUTTONS = { "a", "b", "start", "select" }
local CONTROLS = { "dpad", "a", "b", "start", "select" }
+-- Per-orientation layout buckets (#633). Orientation comes from the safe
+-- rect, not the device: sw > sh is landscape, so a resized desktop window
+-- under POKEPORT_TOUCH exercises the same path a phone rotation does.
+local ORIENTATIONS = { "portrait", "landscape" }
+
+-- Control size multiplier bounds for the editor's -/+ (#633). 1.0 is the
+-- historical size, so an install that never touches it draws as before.
+local SCALE_MIN, SCALE_MAX, SCALE_STEP = 0.6, 1.6, 0.1
+
local IMAGES = {
dpad = "assets/touch/dpad.png",
dpad_up = "assets/touch/dpad_up.png",
@@ -65,6 +78,33 @@ local function clamp01(v)
return v
end
+local function clampScale(v)
+ if type(v) ~= "number" or v ~= v then return 1 end
+ if v < SCALE_MIN then return SCALE_MIN end
+ if v > SCALE_MAX then return SCALE_MAX end
+ return v
+end
+
+-- Copy a persisted positions table, dropping unknown / non-numeric entries.
+-- Always a fresh table: two orientations seeded from the same pre-#633
+-- layout must not alias, or dragging one would still move the other.
+local function normalizePositions(src)
+ if type(src) ~= "table" then return nil end
+ local pos = {}
+ for _, name in ipairs(CONTROLS) do
+ local p = src[name]
+ if type(p) == "table" and type(p.x) == "number" and type(p.y) == "number" then
+ pos[name] = { x = clamp01(p.x), y = clamp01(p.y) }
+ end
+ end
+ if not next(pos) then return nil end
+ return pos
+end
+
+local function orientationFor(sw, sh)
+ return (sw or 0) > (sh or 0) and "landscape" or "portrait"
+end
+
local function wantsOverlay()
local env = os.getenv("POKEPORT_TOUCH")
if env == "1" then return true end
@@ -73,22 +113,26 @@ local function wantsOverlay()
return osName == "Android" or osName == "iOS"
end
--- Normalize a persisted touchControls table into {enabled, positions}.
+-- Normalize a persisted touchControls table into
+-- {enabled, layouts = {portrait = {positions, scale}, landscape = {...}}}.
-- Unknown / garbage keys are dropped so a bad options.lua cannot brick
--- the overlay.
+-- the overlay. Pre-#633 files stored one top-level positions table and no
+-- scale; that layout seeds both orientations, so an upgrading player keeps
+-- what they had until they edit one of them.
function TouchControls.normalizeConfig(tc)
- local out = { enabled = true, positions = nil }
- if type(tc) ~= "table" then return out end
+ local out = { enabled = true, layouts = { portrait = {}, landscape = {} } }
+ -- a nil / garbage table still yields full buckets (scale defaulted), so
+ -- no caller ever has to nil-check a bucket's scale
+ if type(tc) ~= "table" then tc = {} end
if tc.enabled == false then out.enabled = false end
- if type(tc.positions) == "table" then
- local pos = {}
- for _, name in ipairs(CONTROLS) do
- local p = tc.positions[name]
- if type(p) == "table" and type(p.x) == "number" and type(p.y) == "number" then
- pos[name] = { x = clamp01(p.x), y = clamp01(p.y) }
- end
- end
- if next(pos) then out.positions = pos end
+ local saved = type(tc.layouts) == "table" and tc.layouts or nil
+ for _, o in ipairs(ORIENTATIONS) do
+ local b = saved and saved[o]
+ if type(b) ~= "table" then b = { positions = tc.positions, scale = tc.scale } end
+ out.layouts[o] = {
+ positions = normalizePositions(b.positions),
+ scale = clampScale(b.scale),
+ }
end
return out
end
@@ -96,11 +140,14 @@ end
-- Pure default layout in LOVE units for a usable rect of size ww x wh at
-- origin (ox, oy). Shared by layout() and the editor's Reset path so
-- defaults stay in one place. ox/oy default to 0 for the headless tests
--- and for callers that already pass a full-window size.
-function TouchControls.defaultLayout(ww, wh, ox, oy)
+-- and for callers that already pass a full-window size. scale (#633) is
+-- the orientation's size multiplier: every width and the margin derive
+-- from dpadW, so scaling it moves the default centers with the art
+-- instead of letting bigger buttons hang off the edge.
+function TouchControls.defaultLayout(ww, wh, ox, oy, scale)
ox, oy = ox or 0, oy or 0
local short = math.min(ww, wh)
- local dpadW = math.min(180, short * 0.34)
+ local dpadW = math.min(180, short * 0.34) * clampScale(scale)
local abW = dpadW * 0.46
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
@@ -127,7 +174,13 @@ end
function TouchControls:init()
self.active = wantsOverlay()
self.enabled = true
+ -- per-orientation buckets (#633); self.positions / self.scale mirror the
+ -- one currently on screen so layout(), the editor and the tests keep a
+ -- single lookup
+ self.layouts = { portrait = {}, landscape = {} }
+ self.orientation = nil
self.positions = nil
+ self.scale = 1
self.preview = false
self.controllerHidden = false
self.touches = {}
@@ -157,20 +210,30 @@ end
function TouchControls:applyOptions(opts)
local cfg = TouchControls.normalizeConfig(opts and opts.touchControls)
self.enabled = cfg.enabled
- self.positions = cfg.positions
+ self.layouts = cfg.layouts
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
+ -- prime positions/scale for the orientation on screen so callers that
+ -- read them before the next layout() (editor chrome, tests) see the file
+ self:currentBucket()
if not self.enabled then
self.controllerHidden = false
self:reset()
end
end
+-- Snapshot for the editor's save path: enabled plus both orientation
+-- buckets, matching what options.lua stores (#633).
function TouchControls:config()
- return {
- enabled = self.enabled ~= false,
- positions = self.positions,
- }
+ local out = { enabled = self.enabled ~= false, layouts = {} }
+ for _, o in ipairs(ORIENTATIONS) do
+ local b = self.layouts and self.layouts[o] or nil
+ out.layouts[o] = {
+ positions = b and b.positions or nil,
+ scale = clampScale(b and b.scale),
+ }
+ end
+ return out
end
-- Preview mode: force-draw the overlay for the layout editor, ignoring
@@ -197,13 +260,33 @@ local function clampZone(zone, x0, y0, x1, y1)
zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
end
+-- The bucket for the orientation currently on screen (#633), created on
+-- demand. Mirrors it into self.orientation / self.positions / self.scale,
+-- which layout(), the editor chrome and the tests read.
+function TouchControls:currentBucket()
+ local _, _, sw, sh = SafeArea.rect()
+ local o = orientationFor(sw, sh)
+ self.layouts = self.layouts or { portrait = {}, landscape = {} }
+ local b = self.layouts[o]
+ if type(b) ~= "table" then
+ b = {}
+ self.layouts[o] = b
+ end
+ b.scale = clampScale(b.scale)
+ self.orientation = o
+ self.positions = b.positions
+ self.scale = b.scale
+ return b
+end
+
-- Layout in LOVE units (density-independent on mobile), recomputed when
-- the window or safe area changes (rotation, resize, notch insets).
-- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy
-- diagonal), START/SELECT flanking the bottom center -- all inside the
-- device safe area so thumbs clear the home indicator / cutouts.
-- Custom positions (normalized 0..1 within the safe rect) override centers
--- while sizes stay derived from the short edge.
+-- while sizes stay derived from the short edge, times the orientation's
+-- size setting (#633).
function TouchControls:layout()
local ox, oy, sw, sh = SafeArea.rect()
if self.layoutW == sw and self.layoutH == sh
@@ -212,10 +295,13 @@ function TouchControls:layout()
end
self.layoutW, self.layoutH = sw, sh
self.layoutOx, self.layoutOy = ox, oy
- self.L = TouchControls.defaultLayout(sw, sh, ox, oy)
- if self.positions then
+ -- orientation picks which saved layout applies; rotating swaps buckets
+ -- because sw/sh swapped, which is already the cache key above (#633)
+ local bucket = self:currentBucket()
+ self.L = TouchControls.defaultLayout(sw, sh, ox, oy, bucket.scale)
+ if bucket.positions then
for _, name in ipairs(CONTROLS) do
- local p = self.positions[name]
+ local p = bucket.positions[name]
local zone = self.L[name]
if p and zone then
zone.cx = ox + p.x * sw
@@ -242,19 +328,45 @@ function TouchControls:setControlCenter(name, cx, cy)
if not zone then return end
zone.cx, zone.cy = cx, cy
clampZone(zone, ox, oy, ox + sw, oy + sh)
- self.positions = self.positions or {}
- self.positions[name] = {
+ -- writes land in the orientation on screen only (#633)
+ local bucket = self:currentBucket()
+ bucket.positions = bucket.positions or {}
+ self.positions = bucket.positions
+ bucket.positions[name] = {
x = sw > 0 and (zone.cx - ox) / sw or 0,
y = sh > 0 and (zone.cy - oy) / sh or 0,
}
end
+-- Editor Reset: defaults for the orientation on screen only (#633), so
+-- resetting landscape never throws away the portrait layout.
function TouchControls:clearPositions()
+ local bucket = self:currentBucket()
+ bucket.positions = nil
+ bucket.scale = 1
self.positions = nil
+ self.scale = 1
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
end
+-- Control size multiplier for the orientation on screen (#633). Widths and
+-- the default centers both derive from it in defaultLayout; custom centers
+-- keep their normalized spot and re-clamp inside the safe rect on the next
+-- layout().
+function TouchControls:setScale(scale)
+ local bucket = self:currentBucket()
+ bucket.scale = clampScale(scale)
+ self.scale = bucket.scale
+ self.layoutW, self.layoutH = nil, nil
+ self.layoutOx, self.layoutOy = nil, nil
+ return self.scale
+end
+
+function TouchControls:nudgeScale(delta)
+ return self:setScale((self.scale or 1) + delta)
+end
+
local function inCircle(zone, x, y, slop)
local r = zone.w * 0.5 * slop
local dx, dy = x - zone.cx, y - zone.cy
@@ -446,5 +558,8 @@ function TouchControls:draw()
end
TouchControls.CONTROLS = CONTROLS
+TouchControls.ORIENTATIONS = ORIENTATIONS
+TouchControls.SCALE_MIN, TouchControls.SCALE_MAX = SCALE_MIN, SCALE_MAX
+TouchControls.SCALE_STEP = SCALE_STEP
return TouchControls
diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua
index 232fce2e..1d2297f6 100644
--- a/src/import/RomExtractor.lua
+++ b/src/import/RomExtractor.lua
@@ -495,6 +495,25 @@ function RomExtractor:extractSprites()
image = "assets/generated/sprites/red_bike.png",
frames = bikeFrames, walker = bikeFrames >= 6,
}
+ -- Yellow-only: the surfing-Pikachu overworld sheet, loaded outside
+ -- SpriteSheetPointerTable (LoadSurfingPlayerSpriteGraphics2) -- same
+ -- extra-extract shape as RedBikeSprite. (RFC 0001)
+ local surfPika = self.manifest.sprites.surfPikachu
+ if surfPika and self.symbols[surfPika.label] then
+ local spSymbol = self:symbol(surfPika.label)
+ self:write2bpp(
+ self.rom:bytes(spSymbol.bank, spSymbol.address,
+ surfPika.imageWidth * surfPika.imageHeight / 4),
+ surfPika.imageWidth, surfPika.imageHeight,
+ "sprites/" .. surfPika.imageBase .. ".png", true)
+ local spFrames = surfPika.imageHeight / 16
+ out.SPRITE_SURFING_PIKACHU = {
+ id = "SPRITE_SURFING_PIKACHU",
+ source = ("ROM:%s"):format(surfPika.label),
+ image = "assets/generated/sprites/" .. surfPika.imageBase .. ".png",
+ frames = spFrames, walker = spFrames >= 6,
+ }
+ end
self:write("sprites", out)
self:tick("Overworld sprites", #order + 1, #order + 1)
return out
diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua
index b5f9adec..c2aa3991 100644
--- a/src/import/RomImporter.lua
+++ b/src/import/RomImporter.lua
@@ -1727,6 +1727,9 @@ function RomImporter:gamepadaxis(_, axis, value)
end
end
+-- Same gate as src/core/Input.lua: a pad SDL can map already reached
+-- gamepadpressed this frame, so re-entering it from the raw event would
+-- fire the virtual cursor's click twice off one A press (#620).
function RomImporter:joystickpressed(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
local padButton = GamepadMap.mapRawToGamepadButton(button)
@@ -1740,6 +1743,7 @@ function RomImporter:joystickreleased(joystick, button)
end
function RomImporter:joystickaxis(joystick, axis, value)
+ if GamepadMap.ignoreRawForJoystick(joystick) then return end
if axis == 1 then
self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then
@@ -1747,7 +1751,8 @@ function RomImporter:joystickaxis(joystick, axis, value)
end
end
-function RomImporter:joystickhat(_, hat, direction)
+function RomImporter:joystickhat(joystick, hat, direction)
+ if GamepadMap.ignoreRawForJoystick(joystick) then return end
for _, dir in ipairs(self._rawHatDirs[hat] or {}) do
self._padDir[dir] = nil
end
@@ -2013,6 +2018,11 @@ function RomImporter:_resetFrameRects()
self.modRects = nil
self.modDeleteRects = nil
self.modImportRect = nil
+ -- Enable all / Disable all share that header and the same rule (#647): they
+ -- are only drawn when the row is wide enough, so a stale rect would otherwise
+ -- stay clickable over whatever the next tab (or the next window size) draws.
+ self.modEnableAllRect = nil
+ self.modDisableAllRect = nil
-- Same rule as the toggles above, and it started to bite once FIND MODS gave
-- the mods tab a neighbour: these two were rebuilt by the mods panel but
-- never cleared, so switching tabs left the last mod row's Update / Versions
@@ -2920,6 +2930,8 @@ function RomImporter:mousepressed(x, y, button)
self:_findInstall(c.indexEntry)
elseif c.kind == "update" then
self:_confirmModUpdate(c.id, c.release)
+ elseif c.kind == "enableAll" then
+ self:_setAllMods(true, true)
else
self:_toggleMod(c.id, true)
end
@@ -3093,6 +3105,14 @@ function RomImporter:mousepressed(x, y, button)
if inside(self.modImportRect, x, y) then
self:chooseMod(); return
end
+ -- Enable all / Disable all sit in that same fixed header, so they dispatch on
+ -- press like the import button rather than arming a drag (#647).
+ if inside(self.modEnableAllRect, x, y) then
+ self:_setAllMods(true); return
+ end
+ if inside(self.modDisableAllRect, x, y) then
+ self:_setAllMods(false); return
+ end
for _, r in ipairs(self.modDeleteRects or {}) do
if inside(r, x, y) then
if armedDelete(armed, "mod", r.id, nil) then
@@ -4325,6 +4345,49 @@ function RomImporter:_toggleMod(id, confirmed)
self:_refreshMods()
end
+-- Enable all / Disable all (#647). One options write for the whole list
+-- (LauncherMods.setAllEnabled) and one relist afterwards, so the count, the
+-- switches and every status chip resolve together instead of per mod.
+-- Enabling routes through the same experimental confirm _toggleMod arms: that
+-- opt-in is the only warning an experimental mod ever gets, and a bulk button
+-- must not be the way around it. Disabling needs no confirm -- it is the
+-- recovery action, and Delete is the only destructive one on this panel.
+function RomImporter:_setAllMods(want, confirmed)
+ local LauncherMods = require("src.mods.LauncherMods")
+ local ids, experimental = {}, false
+ for _, m in ipairs(self.mods or {}) do
+ if m.enabled ~= want then
+ ids[#ids + 1] = m.id
+ if want and m.experimental then experimental = true end
+ end
+ end
+ if #ids == 0 then
+ self.modNotice = { ok = true, text = want
+ and Strings("Every mod is already enabled.")
+ or Strings("Every mod is already disabled.") }
+ return
+ end
+ if want and experimental and not confirmed then
+ self._modConfirm = {
+ kind = "enableAll",
+ title = "Experimental mods",
+ yesLabel = "Enable all",
+ lines = {
+ "Some of these mods are marked experimental.",
+ "They may be unfinished or unstable.",
+ "Enable everything anyway?",
+ },
+ }
+ return
+ end
+ self._modConfirm = nil
+ LauncherMods.setAllEnabled(ids, want)
+ self:_refreshMods()
+ self.modNotice = { ok = true, text = want
+ and Strings("Enabled %d mods.", #ids)
+ or Strings("Disabled %d mods.", #ids) }
+end
+
-- GitHub Update / Check for updates / Versions. Soft-fails into modNotice.
-- Update button: when a newer release is known, confirm then install; when
-- already current, force-refresh the 6h cache and report / offer update.
@@ -4531,8 +4594,10 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged)
for _, m in ipairs(mods) do if m.enabled then enabledCount = enabledCount + 1 end end
love.graphics.setFont(self.hintFont)
col(PAL.warning)
- love.graphics.print(Strings("%d of %d enabled", enabledCount, #mods),
- x + nameW + 14 * s, y + (headerH - self.hintFont:getHeight()) / 2)
+ local countText = Strings("%d of %d enabled", enabledCount, #mods)
+ local countX = x + nameW + 14 * s
+ love.graphics.print(countText, countX,
+ y + (headerH - self.hintFont:getHeight()) / 2)
local btnLabel = self:_modsImportButtonLabel()
local btnH = math.max(38 * s, self.saveBtnFont:getHeight() + 20 * s)
@@ -4542,6 +4607,32 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged)
self.modImportRect =
self:_glassyButton(btnX, btnY, btnW, btnH, btnLabel, self.saveBtnFont, true)
+ -- Enable all / Disable all (#647): bulk switches beside Import mod .zip, so
+ -- coming back from a cable-club session (which asks for a vanilla fingerprint,
+ -- src/link/Fingerprint.lua) costs one click instead of one switch per mod.
+ -- The header is a single row shared with the count, so the chips are dropped
+ -- rather than overlapped when the column is too narrow to hold them (a
+ -- phone-width layout); the per-mod switches below are always the full path.
+ self.modEnableAllRect, self.modDisableAllRect = nil, nil
+ if #mods > 0 then
+ local enaLabel, disLabel = Strings("Enable all"), Strings("Disable all")
+ love.graphics.setFont(self.hintFont)
+ local bulkH = self.hintFont:getHeight() + 10 * s
+ local bulkY = y + (headerH - bulkH) / 2
+ local enaW = self.hintFont:getWidth(enaLabel) + 24 * s
+ local disW = self.hintFont:getWidth(disLabel) + 24 * s
+ local bulkGap = 8 * s
+ local room = btnX - (countX + self.hintFont:getWidth(countText) + bulkGap)
+ if room >= enaW + disW + 2 * bulkGap then
+ local disX = btnX - bulkGap - disW
+ local enaX = disX - bulkGap - enaW
+ self.modEnableAllRect =
+ self:_chipButton(enaX, bulkY, enaLabel, { w = enaW, h = bulkH })
+ self.modDisableAllRect =
+ self:_chipButton(disX, bulkY, disLabel, { w = disW, h = bulkH })
+ end
+ end
+
local top = y + headerH + 14 * s
-- notice line: the last install/delete result, else the platform hint
diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua
index d60e16a3..ad1e2165 100644
--- a/src/inventory/ItemEffects.lua
+++ b/src/inventory/ItemEffects.lua
@@ -324,6 +324,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
require("src.core.Sound").play(data, "Heal_HP")
-- a revive takes the same .healHP -> .doneHealing route, animating up
-- from the fainted mon's 0 HP (#252)
+ -- re-add to participants so the revived mon gets its share of exp
+ -- at battle end (onFaint clears the flag; revive must restore it)
+ if battle and battle.participants then
+ battle.participants[target] = true
+ end
return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) },
{ healedFrom = 0 }
end
diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua
index 6872a5cc..aec8437e 100644
--- a/src/link/Handshake.lua
+++ b/src/link/Handshake.lua
@@ -40,7 +40,8 @@ function Handshake.mods(game)
if not ok or not status then return mods end
for _, manifest in ipairs(status.loaded or {}) do
mods[#mods + 1] = { id = manifest.id, version = manifest.version,
- affectsLink = manifest.affects_link ~= false }
+ affectsLink = manifest.affects_link ~= false,
+ language = manifest.language == true }
end
table.sort(mods, function(a, b) return tostring(a.id) < tostring(b.id) end)
return mods
@@ -67,15 +68,82 @@ function Handshake.linkModified(game)
return false
end
+-- #501: the registries a declared translation may write, and nothing else.
+-- `text` is the ROM's dialogue, `strings` the engine's own authored text
+-- (src/mods/Schemas.lua R.text / R.strings) and `font` the glyphs a
+-- language with accents needs. None of the three is hashed into the
+-- fingerprint (src/link/Fingerprint.lua header), so two peers reading the
+-- same game in different languages stay in lockstep -- which is also what
+-- the cable did: TradeCenter_PrintPartyListNames (pokered
+-- engine/link/cable_club.asm) names the peer's party out of the local
+-- ROM's table, only the trainer name and the party bytes travel.
+-- text_pointers is deliberately out: its rows carry mart inventories and
+-- nurse/pc flags, which are gameplay, not language.
+local LANGUAGE_REGISTRIES = { text = true, strings = true, font = true }
+
+Handshake.LANGUAGE_REGISTRIES = LANGUAGE_REGISTRIES
+
+-- The manifest's `language = true` is the author's claim; this is the
+-- check. A mod counts as a translation only if every op it appended
+-- landed in a language registry, it subscribed no code (a hook or listener
+-- runs inside the battle the two peers are lockstepping) and it asked for
+-- no permission. Online play meets strangers, so nothing here may rest on
+-- the manifest alone. A patched client can still lie about its own mods --
+-- the fingerprint, not this, is what keeps the shared simulation honest;
+-- this gate is what keeps an honest install from being told to turn its
+-- language off.
+local function translationOnly(mod, id)
+ local record = mod.mods and mod.mods[id]
+ local manifest = record and record.manifest
+ if manifest and #(manifest.permissions or {}) > 0 then return false end
+ for name, registry in pairs(mod.content or {}) do
+ if not LANGUAGE_REGISTRIES[name] then
+ for _, list in pairs(registry.ops or {}) do
+ for _, entry in ipairs(list) do
+ if entry.owner == id then return false end
+ end
+ end
+ end
+ end
+ for _, chain in pairs((mod.hooks and mod.hooks.chains) or {}) do
+ for _, entry in ipairs(chain) do
+ if entry.owner == id then return false end
+ end
+ end
+ for _, list in pairs((mod.events and mod.events.listeners) or {}) do
+ for _, entry in ipairs(list) do
+ if entry.owner == id then return false end
+ end
+ end
+ return true
+end
+
+-- the enabled mods that keep this install out of online play, in the
+-- id order Handshake.mods sorts: everything except verified translations.
+-- LinkState:offerVanillaRestart names these and switches off exactly these.
+function Handshake.onlineBlockers(game)
+ local mod = loader(game)
+ local blockers = {}
+ for _, entry in ipairs(Handshake.mods(game)) do
+ local allowed = entry.language and not entry.affectsLink
+ and mod ~= nil and translationOnly(mod, entry.id)
+ if not allowed then blockers[#blockers + 1] = entry end
+ end
+ return blockers
+end
+
-- online play (the relay-based online match / tournament flows in
-- LinkState/Tournament) meets strangers, not a coordinating friend, so it
-- skips the LAN path's per-peer compatibility negotiation entirely and
--- just requires vanilla on both ends: no mod-added Pokemon, no surprises.
--- Mods only ever get baked in at boot (Loader:load), so this is a gate on
--- attempting to go online, not a live mod toggle -- the player disables
--- mods via the mod manager and relaunches.
+-- just requires a vanilla simulation on both ends: no mod-added Pokemon, no
+-- surprises. #501 carves out translations, because a language is not a
+-- simulation: a mod that only rewrites text is invisible to the wire and
+-- may stay on, so an English player and a Spanish one can meet the way two
+-- regional carts always could. Mods only ever get baked in at boot
+-- (Loader:load), so this is a gate on attempting to go online, not a live
+-- mod toggle -- the player disables mods via the mod manager and relaunches.
function Handshake.onlineAllowed(game)
- return #Handshake.mods(game) == 0
+ return #Handshake.onlineBlockers(game) == 0
end
-- mode is nil on the guest: it pairs and announces itself before the host
diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua
index 016a4802..59d15520 100644
--- a/src/link/LinkState.lua
+++ b/src/link/LinkState.lua
@@ -110,17 +110,19 @@ function LinkState:exitWith(message, reason)
end
end
--- Online play meets strangers, so it requires vanilla on both ends
--- (Handshake.onlineAllowed). Mods merge into the shared Data registries at
--- boot and there is no unmerge, so switching them off has to go through a
--- relaunch -- but the player should not have to go find the mod manager and
--- work out which mods count. This turns every enabled mod off, records
--- them so the mod manager can put them back, and relaunches. The restart
+-- Online play meets strangers, so it requires a vanilla simulation on both
+-- ends (Handshake.onlineAllowed). Mods merge into the shared Data
+-- registries at boot and there is no unmerge, so switching them off has to
+-- go through a relaunch -- but the player should not have to go find the
+-- mod manager and work out which mods count. This turns the blocking mods
+-- off, records them so the mod manager can put them back, and relaunches.
+-- Verified translations are not blockers (#501), so a player keeps their
+-- language across the restart and only the gameplay mods go. The restart
-- is confirmed rather than silent: it drops unsaved progress.
function LinkState:offerVanillaRestart()
local game = self.game
local loader = game.mods
- local mods = Handshake.mods(game)
+ local mods = Handshake.onlineBlockers(game)
local names = {}
for i, mod in ipairs(mods) do
if i > 2 then break end
diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua
index f117be93..2091af91 100644
--- a/src/mods/LauncherMods.lua
+++ b/src/mods/LauncherMods.lua
@@ -265,6 +265,21 @@ function LauncherMods.setEnabled(id, enabled)
return true
end
+-- setAllEnabled(ids, enabled): the launcher's Enable all / Disable all buttons
+-- (#647). Writes exactly the options.mods shape setEnabled does, but loads and
+-- saves once for the whole list: saveOptions rewrites the whole options file per
+-- call, so looping setEnabled over a big mods folder is one disk write per mod
+-- and leaves a half-applied state behind if one of them fails.
+function LauncherMods.setAllEnabled(ids, enabled)
+ local options = SaveData.loadOptions()
+ options.mods = options.mods or {}
+ for _, id in ipairs(ids or {}) do
+ options.mods[id] = enabled and true or false
+ end
+ SaveData.saveOptions(options)
+ return true
+end
+
-- ------- install (love.filesystem)
-- Read a .zip source into bytes. Save-dir-relative paths (inbox /
diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua
index cf750abe..945b7adc 100644
--- a/src/mods/ManagerState.lua
+++ b/src/mods/ManagerState.lua
@@ -11,6 +11,7 @@ local Version = require("src.core.Version")
local Theme = require("src.ui.Theme")
local OptionRows = require("src.ui.OptionRows")
local Strings = require("src.core.Strings")
+local ModProfile = require("src.mods.ModProfile")
local ManagerState = {}
ManagerState.__index = ManagerState
@@ -180,6 +181,16 @@ end
function ManagerState:enter()
self:refresh()
+ -- #593: the setup that existed before profiles shipped becomes PROFILE 1
+ -- the first time the manager opens, so switching away from it is always a
+ -- round trip. Seeded here rather than at boot because profiles are only
+ -- ever read on this screen.
+ local seeded = ModProfile.ensureFirst(self:optionsTable(),
+ self.status.available, self:modOptionsTable())
+ if seeded then
+ self:persistOptions()
+ self:refresh()
+ end
if Runtime.safeMode then
self.banner = "SAFE MODE - ALL MODS OFF"
end
@@ -213,6 +224,12 @@ function ManagerState:optionsTable()
return (save and save.options) or {}
end
+-- Per-mod option values as persisted (options.modOptions; setOption below is
+-- the only writer). A profile captures this alongside the enable set.
+function ManagerState:modOptionsTable()
+ return self:optionsTable().modOptions or {}
+end
+
function ManagerState:manifestMap()
return self.byId or {}
end
@@ -283,6 +300,10 @@ function ManagerState:profileRows()
glyph = opts.activeProfile == p.name and GLYPH.errored or " " }
end
rows[#rows + 1] = { saveAs = true, label = Strings("SAVE CURRENT AS..") }
+ -- #593 sharing: EXPORT writes the focused profile to profiles/*.g1rmodlist
+ -- in the save dir, IMPORT reads back every file dropped in that folder.
+ rows[#rows + 1] = { exportProfile = true, label = Strings("EXPORT..") }
+ rows[#rows + 1] = { importProfile = true, label = Strings("IMPORT..") }
rows[#rows + 1] = { adhoc = true,
label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" }
return rows
@@ -478,6 +499,10 @@ function ManagerState:activate()
self:applyProfile(row.profile)
elseif row.saveAs then
self:saveCurrentAs()
+ elseif row.exportProfile then
+ self:exportActiveProfile()
+ elseif row.importProfile then
+ self:importProfiles()
elseif row.adhoc then
self:optionsTable().activeProfile = nil
self:notify("AD-HOC SET ACTIVE")
@@ -712,9 +737,26 @@ function ManagerState:applyProfile(p)
end
end
self:commitToggle(combined)
+ -- #593: a profile is the whole setup, not just the enable set. Options go
+ -- through setOption so loader.modOptions and mod.options_changed stay in
+ -- step; save slots move only to slots that version already registered
+ -- (SaveData.setActiveSlot would otherwise conjure one).
+ for modId, bucket in pairs(p.options or {}) do
+ if self.byId[modId] then
+ for key, value in pairs(bucket) do self:setOption(modId, key, value) end
+ end
+ end
+ for _, move in ipairs(ModProfile.slotMoves(p)) do
+ require("src.core.SaveData").setActiveSlot(move[1], move[2])
+ end
self:optionsTable().activeProfile = p.name
self:persistOptions()
- self:notify("PROFILE STAGED")
+ local missing = ModProfile.missingIds(p, self.byId)
+ if #missing > 0 then
+ self:notify(#missing .. " MODS MISSING")
+ else
+ self:notify("PROFILE STAGED")
+ end
end
function ManagerState:saveCurrentAs()
@@ -725,16 +767,15 @@ function ManagerState:saveCurrentAs()
onDone = function(name)
local opts = self:optionsTable()
opts.modProfiles = opts.modProfiles or {}
- local enabled = {}
- for _, m in ipairs(self.status.available or {}) do
- enabled[m.id] = m.enabled and true or false
- end
+ local snap = ModProfile.capture(self.status.available,
+ self:modOptionsTable())
local existing = self:findProfile(name)
if existing then
- existing.enabled = enabled
+ existing.enabled, existing.options, existing.slots =
+ snap.enabled, snap.options, snap.slots
else
- opts.modProfiles[#opts.modProfiles + 1] =
- { name = name, enabled = enabled }
+ snap.name = name
+ opts.modProfiles[#opts.modProfiles + 1] = snap
end
opts.activeProfile = name
self:persistOptions()
@@ -743,6 +784,33 @@ function ManagerState:saveCurrentAs()
}))
end
+-- #593 sharing. Export drops the active profile (or the focused one) in the
+-- save directory's profiles/ folder as .g1rmodlist; import reads every
+-- file already there. Deliberately folder-based rather than a native file
+-- picker: the same shape SaveFileIO uses for exports/, and it works on the
+-- mobile builds where no picker exists.
+function ManagerState:exportActiveProfile()
+ local row = self:focusedRow()
+ local p = (row and row.profile)
+ or self:findProfile(self:optionsTable().activeProfile)
+ if not p then return self:notify("NO PROFILE") end
+ local ok = ModProfile.export(p)
+ self:notify(ok and ("SAVED " .. p.name) or "EXPORT FAILED")
+end
+
+function ManagerState:importProfiles()
+ local opts = self:optionsTable()
+ opts.modProfiles = opts.modProfiles or {}
+ local n = 0
+ for _, path in ipairs(ModProfile.files()) do
+ if ModProfile.import(path, opts.modProfiles) then n = n + 1 end
+ end
+ if n == 0 then return self:notify("NO MODLISTS") end
+ self:persistOptions()
+ self:snapCursor()
+ self:notify(n .. " IMPORTED")
+end
+
function ManagerState:renameProfile(p)
local NamingScreen = require("src.ui.NamingScreen")
self.game.stack:push(NamingScreen.new(self.game, {
diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua
index 12ff83a7..97bed0ac 100644
--- a/src/mods/Manifest.lua
+++ b/src/mods/Manifest.lua
@@ -178,9 +178,21 @@ function Manifest.validate(raw, path)
"experimental must be a boolean")
local experimental = raw.experimental == true
+ -- #501: a translation declares itself here. Neither the ROM's dialogue
+ -- nor the engine's own strings are hashed into the link surface
+ -- (src/link/Fingerprint.lua header), so an English install and a Spanish
+ -- one run the same lockstep simulation, exactly as two regional carts on
+ -- a real cable did. The flag is only the author's claim;
+ -- Handshake.onlineBlockers checks it against the ops the mod actually
+ -- appended before online play trusts it.
+ assert(raw.language == nil or type(raw.language) == "boolean",
+ "language must be a boolean")
+ local language = raw.language == true
+
-- overhauls and total conversions are assumed to move the link
- -- fingerprint unless the manifest says otherwise; content packs are not
- local affectsLink = profile ~= "content"
+ -- fingerprint unless the manifest says otherwise; content packs and
+ -- declared translations are not
+ local affectsLink = profile ~= "content" and not language
if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end
local function optionalFile(value, field)
@@ -211,6 +223,7 @@ function Manifest.validate(raw, path)
github = github,
experimental = experimental,
profile = profile,
+ language = language,
affects_link = affectsLink,
permissions = permissions,
permissionSet = permissionSet,
diff --git a/src/mods/ModIndex.lua b/src/mods/ModIndex.lua
index 08b5984f..481669cc 100644
--- a/src/mods/ModIndex.lua
+++ b/src/mods/ModIndex.lua
@@ -520,36 +520,19 @@ function ModIndex.writeCache(feed, index)
return ok
end
--- ------- host I/O (curl, via ModUpdate's shell plumbing)
-
-local function shq(s)
- s = tostring(s)
- if love and love.system and love.system.getOS
- and love.system.getOS() == "Windows" then
- return '"' .. s:gsub('"', '') .. '"'
- end
- return "'" .. s:gsub("'", "'\\''") .. "'"
-end
+-- ------- host I/O (HostShell's transport: curl, or the Android JNI bridge)
-- Plain GET returning the body. No GitHub Accept header: the feed and the
-- description markdown are static files on Pages, and the public feed is
--- explicitly unauthenticated.
+-- explicitly unauthenticated. The transport itself lives in
+-- src/core/HostShell.lua because Android has no curl and has to fetch through
+-- love.system.httpDownload instead (#597).
function ModIndex.httpGet(url)
- local ModUpdate = require("src.mods.ModUpdate")
- if not ModUpdate.haveCurl() then
- return nil, "curl is not available on this platform"
- end
local HostShell = require("src.core.HostShell")
- local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
- .. "-H " .. shq("User-Agent: gen1recomp-mod-index") .. " "
- .. shq(url)
- local pipeOk, pipe = pcall(HostShell.popen, cmd)
- if not pipeOk or not pipe then return nil, "could not run curl" end
- local readOk, out = pcall(function() return pipe:read("*a") end)
- pcall(function() pipe:close() end)
- if not readOk then return nil, "fetch failed: " .. tostring(out) end
- if not out or out == "" then return nil, "empty response from " .. url end
- return out
+ if not HostShell.canFetch() then
+ return nil, "no network transport on this platform"
+ end
+ return HostShell.httpGet(url, "gen1recomp-mod-index")
end
-- fetch(source, opts) -> index, err, meta
diff --git a/src/mods/ModProfile.lua b/src/mods/ModProfile.lua
new file mode 100644
index 00000000..f41181f8
--- /dev/null
+++ b/src/mods/ModProfile.lua
@@ -0,0 +1,206 @@
+-- Mod profiles (#593): a named snapshot of one whole setup -- which mods are
+-- enabled, each mod's own options, and which save slot every game version
+-- plays -- plus the shareable .g1rmodlist file the manager writes and reads.
+-- Pure data and filesystem: src/mods/ManagerState.lua owns the UI and the
+-- staged toggles, and is the only caller.
+--
+-- Records live in options.modProfiles (src/core/SaveData.lua defaultOptions),
+-- so a profile survives New Game exactly like the mod enable-state does:
+-- { name = "PROFILE 1",
+-- enabled = { [modId] = true|false }, -- missing means enabled
+-- options = { [modId] = { key = value } },-- mirrors options.modOptions
+-- slots = { red = "slot1", blue = "slot2", yellow = nil } }
+-- Exports are SaveSerializer-encoded (data-only parse, an imported file can
+-- never execute code) under profiles/.g1rmodlist in the save dir, the
+-- same shape SaveFileIO uses for exports/ (src/import/SaveFileIO.lua).
+
+local SaveSerializer = require("src.core.SaveSerializer")
+local SaveData = require("src.core.SaveData")
+local GameVersion = require("src.core.GameVersion")
+
+local ModProfile = {}
+
+ModProfile.EXT = ".g1rmodlist"
+ModProfile.DIR = "profiles"
+ModProfile.FORMAT = "g1rmodlist"
+ModProfile.FORMAT_VERSION = 1
+
+-- deterministic order; GameVersion.VERSIONS is a map, and a profile file has
+-- to encode the same way twice for a diff to mean anything
+local VERSION_ORDER = { "red", "blue", "yellow" }
+
+local function fsOr(fs)
+ return fs or (love and love.filesystem) or nil
+end
+
+-- Capture the live setup. `available` is ManagerState.status.available (the
+-- loader's status manifests, m.enabled = the desired set including staged
+-- flips); `modOptions` is options.modOptions.
+function ModProfile.capture(available, modOptions)
+ local enabled, options = {}, {}
+ for _, m in ipairs(available or {}) do
+ enabled[m.id] = m.enabled and true or false
+ local bucket = modOptions and modOptions[m.id]
+ if type(bucket) == "table" then
+ local copy = {}
+ for k, v in pairs(bucket) do
+ local t = type(v)
+ if t == "string" or t == "number" or t == "boolean" then copy[k] = v end
+ end
+ options[m.id] = copy
+ end
+ end
+ local slots = {}
+ for _, id in ipairs(VERSION_ORDER) do
+ local ok, slot = pcall(SaveData.activeSlot, id)
+ if ok and slot then slots[id] = slot end
+ end
+ return { enabled = enabled, options = options, slots = slots }
+end
+
+-- The slot moves a profile may actually make: a slot id has to be registered
+-- for that version already, because SaveData.setActiveSlot registers an
+-- unknown id and would conjure a phantom slot the player never made.
+function ModProfile.slotMoves(p)
+ local moves = {}
+ for _, version in ipairs(VERSION_ORDER) do
+ local want = p.slots and p.slots[version]
+ if want then
+ local ok, list = pcall(SaveData.listSlots, version)
+ if ok then
+ for _, row in ipairs(list or {}) do
+ if row.id == want then moves[#moves + 1] = { version, want } end
+ end
+ end
+ end
+ end
+ return moves
+end
+
+-- ids the profile wants enabled that are not installed here. Import cannot
+-- fetch them (community-index install is deferred), so the manager reports
+-- them rather than silently playing a different setup than the sharer's.
+function ModProfile.missingIds(p, byId)
+ local out = {}
+ for id, on in pairs(p.enabled or {}) do
+ if on and not (byId or {})[id] then out[#out + 1] = id end
+ end
+ table.sort(out)
+ return out
+end
+
+function ModProfile.encode(p)
+ return SaveSerializer.encode({
+ format = ModProfile.FORMAT,
+ formatVersion = ModProfile.FORMAT_VERSION,
+ profile = { name = p.name, enabled = p.enabled,
+ options = p.options, slots = p.slots },
+ })
+end
+
+-- Shape-validate rather than trust: a shared file is untrusted input, and the
+-- manager indexes p.enabled/p.options straight into its row models.
+function ModProfile.decode(body)
+ if type(body) ~= "string" then return nil, "EMPTY FILE" end
+ local ok, data = pcall(SaveSerializer.decode, body)
+ if not ok or type(data) ~= "table" then return nil, "BAD FILE" end
+ if data.format ~= ModProfile.FORMAT then return nil, "NOT A MODLIST" end
+ local raw = data.profile
+ if type(raw) ~= "table" or type(raw.name) ~= "string" or raw.name == "" then
+ return nil, "BAD FILE"
+ end
+ local p = { name = raw.name:sub(1, 10), enabled = {}, options = {}, slots = {} }
+ for id, on in pairs(type(raw.enabled) == "table" and raw.enabled or {}) do
+ if type(id) == "string" then p.enabled[id] = on and true or false end
+ end
+ for id, bucket in pairs(type(raw.options) == "table" and raw.options or {}) do
+ if type(id) == "string" and type(bucket) == "table" then
+ local copy = {}
+ for k, v in pairs(bucket) do
+ local t = type(v)
+ if type(k) == "string" and (t == "string" or t == "number" or t == "boolean") then
+ copy[k] = v
+ end
+ end
+ p.options[id] = copy
+ end
+ end
+ for version, slot in pairs(type(raw.slots) == "table" and raw.slots or {}) do
+ if GameVersion.VERSIONS[version] and type(slot) == "string" then
+ p.slots[version] = slot
+ end
+ end
+ return p
+end
+
+-- profiles/.g1rmodlist; letters, digits and dashes only so a shared
+-- name can never escape the folder
+local function fileFor(name)
+ return ModProfile.DIR .. "/" ..
+ tostring(name):upper():gsub("[^%w%-]", "_") .. ModProfile.EXT
+end
+ModProfile.fileFor = fileFor
+
+function ModProfile.export(p, fs)
+ fs = fsOr(fs)
+ if not (fs and fs.write) then return false, "NO FILESYSTEM" end
+ if fs.createDirectory then fs.createDirectory(ModProfile.DIR) end
+ local rel = fileFor(p.name)
+ local ok, err = fs.write(rel, ModProfile.encode(p))
+ if not ok then return false, tostring(err) end
+ local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
+ if base ~= "" then return true, base .. "/" .. rel end
+ return true, rel
+end
+
+-- every .g1rmodlist sitting in profiles/, sorted, as relative paths
+function ModProfile.files(fs)
+ fs = fsOr(fs)
+ local out = {}
+ if not (fs and fs.getDirectoryItems) then return out end
+ if fs.getInfo and not fs.getInfo(ModProfile.DIR) then return out end
+ for _, name in ipairs(fs.getDirectoryItems(ModProfile.DIR) or {}) do
+ if name:sub(-#ModProfile.EXT) == ModProfile.EXT then
+ out[#out + 1] = ModProfile.DIR .. "/" .. name
+ end
+ end
+ table.sort(out)
+ return out
+end
+
+-- Read one export and land it in `profiles` (options.modProfiles) under a
+-- non-colliding name, so importing twice never overwrites what the player
+-- already tuned. Returns the stored record, or nil + a short reason.
+function ModProfile.import(path, profiles, fs)
+ fs = fsOr(fs)
+ if not (fs and fs.read) then return nil, "NO FILESYSTEM" end
+ local p, err = ModProfile.decode(fs.read(path))
+ if not p then return nil, err end
+ local taken = {}
+ for _, existing in ipairs(profiles) do taken[existing.name] = true end
+ local name, n = p.name, 1
+ while taken[name] do
+ n = n + 1
+ name = (p.name:sub(1, 8) .. n)
+ end
+ p.name = name
+ profiles[#profiles + 1] = p
+ return p
+end
+
+-- First run after this update: the setup the player already has becomes
+-- PROFILE 1, then they can add more (#593). Seeded once and remembered by
+-- options.modProfilesSeeded, so deleting every profile does not resurrect it.
+function ModProfile.ensureFirst(opts, available, modOptions)
+ if opts.modProfilesSeeded then return nil end
+ opts.modProfilesSeeded = true
+ opts.modProfiles = opts.modProfiles or {}
+ if #opts.modProfiles > 0 then return nil end
+ local p = ModProfile.capture(available, modOptions)
+ p.name = "PROFILE 1"
+ opts.modProfiles[1] = p
+ opts.activeProfile = p.name
+ return p
+end
+
+return ModProfile
diff --git a/src/mods/ModUpdate.lua b/src/mods/ModUpdate.lua
index 71d87b6b..ce69328b 100644
--- a/src/mods/ModUpdate.lua
+++ b/src/mods/ModUpdate.lua
@@ -254,39 +254,21 @@ function ModUpdate.statusFor(installedVersion, releases)
return "current", best
end
--- ------- host I/O (curl)
+-- ------- host I/O (HostShell's transport: curl, or the Android JNI bridge)
-local function shq(s)
- s = tostring(s)
- if love and love.system and love.system.getOS
- and love.system.getOS() == "Windows" then
- return '"' .. s:gsub('"', '') .. '"'
- end
- return "'" .. s:gsub("'", "'\\''") .. "'"
-end
-
-local function curlCapture(url)
+-- GitHub's API wants a User-Agent and answers the versioned Accept.
+local function apiFetch(url)
local HostShell = require("src.core.HostShell")
- local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
- .. "-H " .. shq("User-Agent: gen1recomp-mod-updater") .. " "
- .. "-H " .. shq("Accept: application/vnd.github+json") .. " "
- .. shq(url)
- local pipeOk, pipe = pcall(HostShell.popen, cmd)
- if not pipeOk or not pipe then return nil, "could not run curl" end
- local readOk, out = pcall(function() return pipe:read("*a") end)
- pcall(function() pipe:close() end)
- if not readOk then return nil, "curl read failed: " .. tostring(out) end
- if not out or out == "" then return nil, "empty response from GitHub" end
- return out
+ return HostShell.httpGet(url, "gen1recomp-mod-updater",
+ "application/vnd.github+json")
end
+-- Still just the curl probe it always was. "Can we fetch at all" is
+-- HostShell.canFetch, which also knows about the Android bridge (#597); every
+-- gate below asks that instead.
function ModUpdate.haveCurl()
local HostShell = require("src.core.HostShell")
- local pipeOk, pipe = pcall(HostShell.popen, "curl --version")
- if not pipeOk or not pipe then return false end
- local readOk, out = pcall(function() return pipe:read("*a") end)
- pcall(function() pipe:close() end)
- return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
+ return HostShell.haveCurl()
end
-- Fetch release list. opts.force bypasses the 6h cache.
@@ -302,15 +284,16 @@ function ModUpdate.fetchReleases(repo, modId, opts)
return cached.releases, nil, { fromCache = true }
end
end
- if not ModUpdate.haveCurl() then
+ local HostShell = require("src.core.HostShell")
+ if not HostShell.canFetch() then
-- Stale cache is better than nothing when offline
local cached = ModUpdate.readCache(repo)
if cached and cached.releases then
return cached.releases, nil, { fromCache = true, stale = true }
end
- return nil, "curl is not available on this platform"
+ return nil, "no network transport on this platform"
end
- local body, curlErr = curlCapture(ModUpdate.apiReleasesUrl(repo))
+ local body, curlErr = apiFetch(ModUpdate.apiReleasesUrl(repo))
if not body then
local cached = ModUpdate.readCache(repo)
if cached and cached.releases then
@@ -331,10 +314,10 @@ function ModUpdate.downloadZip(url, destName)
if not (love and love.filesystem) then
return nil, "download needs LOVE"
end
- if not ModUpdate.haveCurl() then
- return nil, "curl is not available on this platform"
- end
local HostShell = require("src.core.HostShell")
+ if not HostShell.canFetch() then
+ return nil, "no network transport on this platform"
+ end
local name = destName or ("mod_update_" .. tostring(os.time()) .. ".zip")
name = tostring(name):gsub("[/\\]", "_")
local saveOk, saveDir = pcall(function()
@@ -344,12 +327,9 @@ function ModUpdate.downloadZip(url, destName)
return nil, "no save directory"
end
local abs = saveDir .. "/" .. name
- local cmd = "curl -fsSL --connect-timeout 15 --max-time 300 -o "
- .. shq(abs) .. " " .. shq(url)
- local pipeOk, pipe = pcall(HostShell.popen, cmd)
- if not pipeOk or not pipe then return nil, "could not start download" end
- pcall(function() pipe:read("*a") end)
- pcall(function() pipe:close() end)
+ -- Either transport can fail quietly; the getInfo size check below is what
+ -- actually decides whether we got a file (#597).
+ HostShell.httpDownload(url, abs, "gen1recomp-mod-updater")
local infoOk, info = pcall(love.filesystem.getInfo, name)
if not infoOk or not info or (info.size or 0) == 0 then
pcall(love.filesystem.remove, name)
diff --git a/src/render/BattleTransition.lua b/src/render/BattleTransition.lua
index ab5d8e29..aa214344 100644
--- a/src/render/BattleTransition.lua
+++ b/src/render/BattleTransition.lua
@@ -32,10 +32,23 @@ local FLASH_CYCLES = 3
-- InitBattleCommon decompresses the front pic (core.asm:6694-6730), and
-- SlidePlayerAndEnemySilhouettesOnScreen rebuilds the whole tilemap between
-- DisableLCD and EnableLCD (core.asm:9-49) before anything moves. This port
--- has no load to hide behind, so the hold has to be explicit (#315). 30 is a
--- frame budget for that work, not a number pokered states; retune this one
--- constant against a reference recording if it reads long or short.
-local BLACK_HOLD = 30
+-- has no load to hide behind, so the hold has to be explicit (#315). This is
+-- a frame budget for that work, not a number pokered states.
+--
+-- 60 comes from pokered-c (BTRANS_BLACK_HOLD_FRAMES), which itemized the
+-- derivable floor at ~13 frames -- LoadHpBarAndStatusTilePatterns 4,
+-- LoadHudTilePatterns 2, ClearScreen's Delay3 3, the DisableLCD LY wait 1,
+-- Delay3 after EnableLCD 3 -- and then noted that the two sprite
+-- decompressors (UncompressSpriteFromDE for the 7x7 front pic,
+-- LoadPlayerBackPic's uncompress + ScaleSpriteByTwo) are bit-level RLE/delta
+-- decoders whose cost cannot be cycle-counted from the asm at all. So the
+-- derivation bottoms out around 25-30 with an unbounded remainder, and 60
+-- was set by ear against the real ROM ("a solid second") and confirmed as
+-- ~95% right rather than frame-matched. The credible range is 30-60; do NOT
+-- "correct" this down toward the floor on the strength of the derivation,
+-- because the omitted decompressors are exactly the unbounded part. It
+-- wants a frame-by-frame capture against hardware to pin exactly.
+local BLACK_HOLD = 60
local TILE = 8
local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles
@@ -106,11 +119,12 @@ end
-- HalfCircle2 continues (1,11) down under the bottom back to (18,11)).
-- arms = 1 (Circle, halves in sequence) or 2 (DoubleCircle, both halves
-- at once, so opposite arms)
-local function sweepOrder(arms)
- local cx, cy = COLS / 2, ROWS / 2
+local function sweepOrder(arms, cols, rows)
+ cols, rows = cols or COLS, rows or ROWS
+ local cx, cy = cols / 2, rows / 2
local tiles = {}
- for y = 0, ROWS - 1 do
- for x = 0, COLS - 1 do
+ for y = 0, rows - 1 do
+ for x = 0, cols - 1 do
local a = math.atan2(cy - (y + 0.5), x + 0.5 - cx)
if a < 0 then a = a + 2 * math.pi end
if arms == 2 then a = a % math.pi end
@@ -121,18 +135,116 @@ local function sweepOrder(arms)
return tiles
end
+-- ---------------------------------------------------------------------
+-- Arbitrary-grid wipes, for the surface OUTSIDE the classic letterbox
+-- ---------------------------------------------------------------------
+--
+-- The builders above reproduce the ROM's exact walks on its 20x18 tilemap,
+-- overrun and all, and stay the authority inside the 160x144 box. A zoomed
+-- or windowed surface has more grid than the Game Boy ever had, and no
+-- hardware behaviour to be faithful to out there -- but filling it with a
+-- generic square cascade made a spiral read as "a spiral in a box, with
+-- something else happening around it". These generalise the same shapes to
+-- whatever grid the window works out to so the whole surface wipes as one
+-- figure.
+
+-- perimeter inward, counterclockwise, starting down the left edge -- the
+-- direction BattleTransition_InwardSpiral walks
+local function spiralInGrid(cols, rows)
+ local order = {}
+ local x0, y0, x1, y1 = 0, 0, cols - 1, rows - 1
+ while x0 <= x1 and y0 <= y1 do
+ for y = y0, y1 do order[#order + 1] = { x0, y } end
+ x0 = x0 + 1
+ if x0 > x1 then break end
+ for x = x0, x1 do order[#order + 1] = { x, y1 } end
+ y1 = y1 - 1
+ if y0 > y1 then break end
+ for y = y1, y0, -1 do order[#order + 1] = { x1, y } end
+ x1 = x1 - 1
+ if x0 > x1 then break end
+ for x = x1, x0, -1 do order[#order + 1] = { x, y0 } end
+ y0 = y0 + 1
+ end
+ return order
+end
+
+-- the outward spiral is the same walk read from the middle out
+local function spiralOutGrid(cols, rows)
+ local inward = spiralInGrid(cols, rows)
+ local order = {}
+ for i = #inward, 1, -1 do order[#order + 1] = inward[i] end
+ return order
+end
+
+local GRID_BUILDERS = {
+ spiralin = spiralInGrid,
+ spiralout = spiralOutGrid,
+ circle = function(c, r) return sweepOrder(1, c, r) end,
+ doublecircle = function(c, r) return sweepOrder(2, c, r) end,
+}
+
+-- Tile order for `style` on an arbitrary cols x rows grid, or nil for the
+-- styles whose shape is plain geometry (stripes / shrink / split) and which
+-- the caller extends with rectangles instead. Cached per style+size: the
+-- window grid only changes on a resize or a zoom step.
+local orderFor -- defined below; the authentic 20x18 builders
+
+local gridCache = {}
+function BattleTransition.gridOrder(style, cols, rows)
+ local build = GRID_BUILDERS[style]
+ if not build or cols < 1 or rows < 1 then return nil end
+ -- At exactly the Game Boy's grid the ROM's own walk is the answer, overrun
+ -- and all -- so an unzoomed window is the classic wipe, not a lookalike.
+ if cols == COLS and rows == ROWS then return orderFor(style, nil) end
+ local key = style .. ":" .. cols .. "x" .. rows
+ local hit = gridCache[key]
+ if hit == nil then
+ hit = build(cols, rows) or false
+ gridCache[key] = hit
+ end
+ return hit or nil
+end
+
+-- Wipe lengths, taken from pokered-c's battle_transition.c frame budget --
+-- derived from battle_transitions.asm and then checked on a live
+-- side-by-side against the ROM. Each wipe is `steps x frames-per-step`:
+--
+-- DoubleCircle 10 x 3 = 30 SpiralOut 360 fills / 3 per frame = 120
+-- Circle 20 x 3 = 60 HStripes 20 x 3 = 60
+-- Shrink 9 x 6 = 54 VStripes 18 x 3 = 54
+-- Split 9 x 6 = 54 (asm:386-392 and :418-424)
+--
+-- The port used a flat 40/24 for all eight, which ran every wipe between
+-- 1.5x and 3x too fast -- the single biggest reason a battle used to open
+-- so much more abruptly here than on hardware.
+--
+-- The inward spiral is the one that bites. It writes one tile per
+-- iteration and calls BattleTransition_TransferDelay3 every seventh tile
+-- (wInwardSpiralUpdateScreenCounter counts 7 down to 0), and that helper is
+-- `ld a,1 / ldh [hAutoBGTransferEnabled] / call Delay3 / xor a / ldh [...]`
+-- (battle_transitions.asm:619) -- THREE frames, not a one-frame transfer.
+-- Reading it as one frame runs the whole spiral 3x too fast; pokered-c
+-- caught that against the real ROM. Deriving the length from the path we
+-- actually walk keeps the cadence right if the order ever changes.
+local SPIRAL_IN_TILES_PER_STEP = 7
+local SPIRAL_IN_STEP_FRAMES = 3 -- TransferDelay3
+local SPIRAL_IN_FRAMES = math.ceil(#inwardSpiralOrder()
+ / SPIRAL_IN_TILES_PER_STEP)
+ * SPIRAL_IN_STEP_FRAMES
+
-- The eight wipes as records: frames is the wipe length, flash marks the
-- two circle wipes that call BattleTransition_FlashScreen first. new()
-- reads them, and the transitions registry serves the same table.
BattleTransition.STYLES = {
- doublecircle = { kind = "wipe", frames = 40, flash = true },
- spiralin = { kind = "wipe", frames = 40 },
- circle = { kind = "wipe", frames = 40, flash = true },
- spiralout = { kind = "wipe", frames = 40 },
- hstripes = { kind = "wipe", frames = 24 },
- shrink = { kind = "wipe", frames = 24 },
- vstripes = { kind = "wipe", frames = 24 },
- split = { kind = "wipe", frames = 24 },
+ doublecircle = { kind = "wipe", frames = 30, flash = true },
+ spiralin = { kind = "wipe", frames = SPIRAL_IN_FRAMES },
+ circle = { kind = "wipe", frames = 60, flash = true },
+ spiralout = { kind = "wipe", frames = 120 },
+ hstripes = { kind = "wipe", frames = 60 },
+ shrink = { kind = "wipe", frames = 54 },
+ vstripes = { kind = "wipe", frames = 54 },
+ split = { kind = "wipe", frames = 54 },
}
-- the eight wipes plus Transition's two warp fades: one registrant owns
@@ -164,7 +276,7 @@ local BUILTIN_ORDERS = {
-- A registered style may bring its own tile order (a list of {x, y}, or a
-- function returning one); the four built-in orders are the defaults for
-- the styles that have always had them.
-local function orderFor(style, def)
+function orderFor(style, def)
if ORDERS[style] == nil then
local order = def and def.order
if type(order) == "function" then
@@ -235,6 +347,15 @@ function BattleTransition:draw()
local v = FLASH_STEPS[step]
if v ~= 0 then
local shade = v > 0 and 0 or 1
+ -- The flash is a palette write (rBGP), so on hardware it tints every
+ -- pixel the LCD shows. Hand it to the renderer as a screen-space veil
+ -- so it covers the whole surface at any zoom; only the headless and
+ -- no-renderer paths fall back to filling the 160x144 box.
+ local r = self.game and self.game.renderer
+ if r then
+ r.screenVeil = { shade, math.abs(v) }
+ return
+ end
love.graphics.setColor(shade, shade, shade, math.abs(v))
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
@@ -242,22 +363,35 @@ function BattleTransition:draw()
return
end
- love.graphics.setColor(0, 0, 0, 1)
local prog = math.min(1, self.t / self.wipeLen)
- -- Cascade black 8x8 blocks across the window area *outside* the classic
- -- 160x144 wipe square, in lockstep with the OG wipe progress. Renderer
- -- paints them in screen space after the world blit (see endFrame).
- local renderer = self.game and self.game.renderer
- if renderer then renderer.battleCascadeProg = prog end
local style = self.style
- -- a registered style may draw itself; the eight built-ins do not
+ -- a registered style may draw itself; the eight built-ins do not. A custom
+ -- draw owns the 160x144 UI canvas as it always has.
if self.def and self.def.draw then
+ love.graphics.setColor(0, 0, 0, 1)
self.def.draw(self, prog)
love.graphics.setColor(1, 1, 1, 1)
return
end
+ -- With a renderer the wipe is drawn ONCE over the whole surface in screen
+ -- space (Renderer:drawBattleWipe), on a tile grid anchored to the letterbox
+ -- and extended outward at the same tile size. That makes it a single
+ -- continuous figure: the spiral starts at the outermost edge of the window
+ -- and works inward, instead of one spiral inside the letterbox running
+ -- alongside a second one outside it. Nothing is stretched -- the pattern is
+ -- continued with more tiles, not scaled-up pixels -- so at 1x the grid works
+ -- out to exactly 20x18 and this is the classic wipe unchanged.
+ local renderer = self.game and self.game.renderer
+ if renderer then
+ renderer.battleWipe = { style = style, prog = prog }
+ return
+ end
+
+ -- headless / no renderer: the classic 160x144 path
+ love.graphics.setColor(0, 0, 0, 1)
+
local order = orderFor(style, self.def)
if order then
-- tile-order wipes: spiral / circle sweeps
diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua
index 7990f88b..064b86fe 100644
--- a/src/render/PaletteFX.lua
+++ b/src/render/PaletteFX.lua
@@ -559,16 +559,26 @@ local TILESET_GROUP_EXCEPTIONS = {
}
-- pokered-gbc's lobby.bst repoints the Celadon LOBBY table's flat top
--- (block 29, cells 5/6/9/10) at a duplicate tile ($5a, BROWN) so the
--- tabletop and the checkerboard floor -- both raw tile $37 -- can take
--- different palettes; the vanilla-derived blockset shares the one tile
--- id, so the RED++ atlas path re-creates the duplicate: the alias slot
--- is baked as a copy of `tile` in `group`'s colors, and the listed
--- 0-based block cells draw the alias instead of the shared tile.
--- Same block appears on CELADON_MART_ROOF (#52) and CELADON_DINER (#84).
+-- at a duplicate tile ($5a, BROWN) so the tabletop and the checkerboard
+-- floor -- both raw tile $37 -- can take different palettes; the
+-- vanilla-derived blockset shares the one tile id, so the RED++ atlas
+-- path re-creates the duplicate: the alias slot is baked as a copy of
+-- `tile` in `group`'s colors, and the listed 0-based block cells draw
+-- the alias instead of the shared tile.
+--
+-- Three LOBBY blocks share tile $37 on their flat surfaces:
+-- block 29: 2x2 table top at cells 5/6/9/10
+-- block 45: 2-tile strip at cells 13/14
+-- block 49: 2-tile strip at cells 1/2
+-- CELADON_DINER uses all three (#84, #85, #86); CELADON_MART_ROOF
+-- uses only block 29 (#52/#53).
local LOBBY_TABLE_TOP_ALIAS = {
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
tile = 0x37, alias = 0x5a, group = 5 },
+ { block = 45, cells = { [13] = true, [14] = true },
+ tile = 0x37, alias = 0x5a, group = 5 },
+ { block = 49, cells = { [1] = true, [2] = true },
+ tile = 0x37, alias = 0x5a, group = 5 },
}
PaletteFX.TILE_ALIASES = {
CELADON_MART_ROOF = LOBBY_TABLE_TOP_ALIAS,
@@ -646,10 +656,13 @@ function PaletteFX.spriteObp(spriteDef, seed)
local src = spriteDef and (spriteDef.paletteSource or spriteDef.source)
if not (w and src) then return nil end
local idx = tonumber(src:match("%[(%d+)%]"))
- -- RedBikeSprite loads outside SpriteSheetPointerTable
- -- (LoadBikePlayerSpriteGraphics), so its source carries no bracketed
- -- index; it wears the player's own palette, same as SPRITE_RED
- if not idx and src:find("RedBikeSprite", 1, true) then idx = 0 end
+ -- RedBikeSprite and SurfingPikachuSprite load outside
+ -- SpriteSheetPointerTable, so their source has no bracketed index;
+ -- they wear the player's OBP palette (spriteAssignment[0]).
+ if not idx and (src:find("RedBikeSprite", 1, true)
+ or src:find("SurfingPikachuSprite", 1, true)) then
+ idx = 0
+ end
local group = idx and w.spriteAssignment[idx]
if group == nil then return nil end
if group == "random" then
diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua
index e3513c1d..28618cb2 100644
--- a/src/render/Renderer.lua
+++ b/src/render/Renderer.lua
@@ -12,6 +12,8 @@ local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local PixelCanvas = require("src.render.PixelCanvas")
local Runtime = require("src.mods.Runtime")
+-- leaf module (no renderer dependency), so requiring it here cannot cycle
+local FaithfulRes = require("src.core.FaithfulRes")
local Renderer = {}
@@ -124,7 +126,61 @@ end
function Renderer:fitScale()
local _, _, pw, ph = displayMetrics()
local w, h = self:uiSize()
- return math.max(1, math.floor(math.min(pw / w, ph / h)))
+ local s = math.max(1, math.floor(math.min(pw / w, ph / h)))
+ -- FAITHFUL RATIO on mobile locks the scale here rather than by resizing the
+ -- window, which a phone does not have (see src/core/FaithfulRes.lua). The
+ -- cap is the largest WHOLE multiple the display holds, so the picture is as
+ -- big as exact pixels allow and the remainder is bars. Computed per frame
+ -- off the live drawable size, so a rotate re-derives it with nothing to
+ -- re-apply.
+ local cap = FaithfulRes.scaleCap()
+ if cap and cap < s then s = cap end
+ return s
+end
+
+-- Integer framebuffer pixels per GB pixel for the UI pass.
+--
+-- Survey zoom only ever scaled the world: the UI kept blitting at fitScale,
+-- so zooming out left a full-size dialogue box over a shrunken world, which
+-- reads as the UI growing. Stepping the UI down with the zoom keeps the two
+-- in proportion. Whole integers only, so a UI pixel stays a whole number of
+-- screen pixels and the font does not resample; and never below half of
+-- fitScale, because past that the text stops being readable.
+--
+-- Zooming IN does not scale the UI up -- a dialogue box larger than the
+-- classic one has no reference to be faithful to, and the letterbox it sits
+-- in does not grow either.
+function Renderer:uiScale()
+ local S = self:fitScale()
+ local off = Zoom.offset or 0
+ -- UI LAYOUT = CENTERED (uiCentered, set per frame by Game:draw): the UI is
+ -- a fixed letterbox at the fit scale and does not follow the survey zoom at
+ -- all, which is the whole point of the setting -- the screen furniture
+ -- stays put instead of resizing under the player. DYNAMIC keeps the
+ -- step-down below. BATTLE SIZE is unaffected either way: uiFill overrides
+ -- the scale in endFrame, after this.
+ if self.uiCentered then return S end
+ -- Only follow the zoom when a world is actually behind the UI. Survey zoom
+ -- is an OVERWORLD control; the title screen, the intro and the credits show
+ -- no world at all, and shrinking them to match a zoom level the player set
+ -- for the map is meaningless. worldActive is this frame's answer --
+ -- beginFrame clears it and beginWorldPass sets it -- so a state that draws
+ -- no world keeps the full fit scale.
+ --
+ -- uiWorldHold (Game:draw) is the case worldActive cannot answer: a BATTLE
+ -- BG "world" battle stays the scene's backdrop while an OPAQUE state -- the
+ -- party menu, the bag -- covers it, whether or not a world pass ran under
+ -- that state. Reading worldActive alone drops the step-down there and
+ -- blits those menus a whole scale larger than the battle they cover.
+ -- Game.drawBaseInStack keeps the map drawing in the common case, so the two
+ -- normally agree; this holds the scale even when it cannot (a battle with
+ -- something other than the overworld beneath it).
+ if not (self.worldActive or self.uiWorldHold) then return S end
+ if off >= 0 then return S end
+ local floorS = math.ceil(S / 2) -- at most a 50% reduction
+ local s = S + off -- one integer step per zoom-out step
+ if s < floorS then s = floorS end
+ return math.max(1, s)
end
-- the native-pixel UI surface in use right now
@@ -179,6 +235,21 @@ end
-- tilt is inactive).
function Renderer:worldViewSize()
local _, _, pw, ph = displayMetrics()
+ -- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
+ -- WHOLE display, so letterbox voids become more map instead of black bars.
+ -- That is why the lock appeared to do nothing in the overworld: it shrank
+ -- the UI blit while the map kept filling the screen -- and showed MORE of
+ -- the map, because a smaller scale fits more world pixels in.
+ --
+ -- Size the view against the LOCKED VIEWPORT rather than the display. A
+ -- desktop lock gets this for free by making the window exactly 160N x 144N;
+ -- this is the same sum with the viewport standing in for the window, so
+ -- both platforms show the same map area at the same zoom.
+ local cap = FaithfulRes.scaleCap()
+ if cap then
+ local uiw, uih = self:uiSize()
+ pw, ph = uiw * cap, uih * cap
+ end
local sp = Zoom.scale(self:fitScale())
local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
@@ -200,8 +271,14 @@ function Renderer:beginFrame(transparent)
-- warp-fade overlay from Transition (issue #121); cleared each frame so
-- a popped transition cannot leave a sticky black veil
self.worldFadeAlpha = nil
- -- battle-transition cascade outside the 160x144 wipe (BattleTransition)
- self.battleCascadeProg = nil
+ -- battle-transition wipe, drawn over the whole surface (BattleTransition)
+ self.battleWipe = nil
+ -- whole-surface veil in screen space (battle-transition flash, the
+ -- fade in from white after a battle) -- covers the window, not just the
+ -- 160x144 letterbox
+ self.screenVeil = nil
+ -- edge-anchored UI regions, re-declared by their elements each frame
+ self.uiAnchors = nil
-- last frame's trueColor rects and sprite redraws go before anything
-- draws this one
PaletteFX.clearTrueColor()
@@ -218,44 +295,94 @@ function Renderer:beginFrame(transparent)
end
end
--- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox
--- into the surrounding window, matching BattleTransition wipe progress.
--- Tiles that sit entirely inside the 160x144 square are left to the OG wipe.
+-- The battle-transition wipe, drawn once over the WHOLE surface in screen
+-- space rather than as a 160x144 wipe plus a separate fill around it.
+--
+-- The tile grid is anchored on the letterbox and extended outward at the same
+-- tile size, so the figure is continuous: a spiral begins at the outermost
+-- edge of the window and works inward through the letterbox to the middle.
+-- Nothing is stretched -- the pattern is continued with more tiles, not
+-- scaled-up pixels -- and at 1x the grid works out to exactly 20x18, where
+-- BattleTransition.gridOrder hands back the ROM's own walk, so an unzoomed
+-- window is the classic wipe unchanged.
+--
-- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces).
-function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, Sx, Sy)
- if not prog or prog <= 0 then return end
+function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
+ if not wipe or not wipe.prog or wipe.prog <= 0 then return end
Sy = Sy or Sx
- local TILE_W, TILE_H = 8 * Sx, 8 * Sy
- if TILE_W < 1 then TILE_W = 1 end
- if TILE_H < 1 then TILE_H = 1 end
- local cols = math.ceil(ww / TILE_W)
- local rows = math.ceil(wh / TILE_H)
- local cx, cy = ox + vpw / 2, oy + vph / 2
- local order = {}
- for row = 0, rows - 1 do
- for col = 0, cols - 1 do
- local x, y = col * TILE_W, row * TILE_H
- -- any tile with area outside the letterbox participates
- if x < ox or y < oy or x + TILE_W > ox + vpw or y + TILE_H > oy + vph then
- local dist = math.max(math.abs(x + TILE_W / 2 - cx),
- math.abs(y + TILE_H / 2 - cy))
- order[#order + 1] = { x, y, dist }
- end
- end
- end
- if #order == 0 then return end
- table.sort(order, function(a, b)
- if a[3] ~= b[3] then return a[3] < b[3] end
- if a[2] ~= b[2] then return a[2] < b[2] end
- return a[1] < b[1]
- end)
- local n = math.floor(#order * math.min(1, prog) + 1e-6)
- if prog >= 1 then n = #order end
+ local TW, TH = 8 * Sx, 8 * Sy
+ if TW < 1 then TW = 1 end
+ if TH < 1 then TH = 1 end
+ local prog = math.min(1, wipe.prog)
+
love.graphics.setColor(0, 0, 0, 1)
love.graphics.setScissor(0, 0, ww, wh)
- for i = 1, n do
- local t = order[i]
- love.graphics.rectangle("fill", t[1], t[2], TILE_W, TILE_H)
+
+ if prog >= 1 then
+ love.graphics.rectangle("fill", 0, 0, ww, wh)
+ love.graphics.setScissor()
+ love.graphics.setColor(1, 1, 1, 1)
+ return
+ end
+
+ -- whole-tile padding out to each window edge, keeping the grid in phase
+ -- with the letterbox's tiles
+ local padL = math.max(0, math.ceil(ox / TW))
+ local padT = math.max(0, math.ceil(oy / TH))
+ local padR = math.max(0, math.ceil((ww - ox - vpw) / TW))
+ local padB = math.max(0, math.ceil((wh - oy - vph) / TH))
+ local lbCols = math.max(1, math.floor(vpw / TW + 0.5))
+ local lbRows = math.max(1, math.floor(vph / TH + 0.5))
+ local cols, rows = padL + lbCols + padR, padT + lbRows + padB
+ local x0, y0 = ox - padL * TW, oy - padT * TH
+
+ -- required here rather than at the top: BattleTransition reaches the
+ -- renderer through game.renderer at draw time, and a module-level require
+ -- would put the two files in a load cycle
+ local BattleTransition = require("src.render.BattleTransition")
+ local order = BattleTransition.gridOrder(wipe.style, cols, rows)
+
+ if order then
+ local n = math.floor(#order * prog + 1e-6)
+ for i = 1, n do
+ local t = order[i]
+ love.graphics.rectangle("fill", x0 + t[1] * TW, y0 + t[2] * TH, TW, TH)
+ end
+ else
+ -- shrink / split / the stripes are geometry, not a walk: the same shapes
+ -- measured against the window instead of the letterbox
+ local style = wipe.style
+ if style == "hstripes" then
+ local w = ww * prog
+ for row = 0, rows - 1 do
+ local y = y0 + row * TH
+ if row % 2 == 0 then
+ love.graphics.rectangle("fill", 0, y, w, TH)
+ else
+ love.graphics.rectangle("fill", ww - w, y, w, TH)
+ end
+ end
+ elseif style == "vstripes" then
+ local h = wh * prog
+ for col = 0, cols - 1 do
+ local x = x0 + col * TW
+ if col % 2 == 0 then
+ love.graphics.rectangle("fill", x, 0, TW, h)
+ else
+ love.graphics.rectangle("fill", x, wh - h, TW, h)
+ end
+ end
+ elseif style == "shrink" then
+ local h, w = wh / 2 * prog, ww / 2 * prog
+ love.graphics.rectangle("fill", 0, 0, ww, h)
+ love.graphics.rectangle("fill", 0, wh - h, ww, h)
+ love.graphics.rectangle("fill", 0, 0, w, wh)
+ love.graphics.rectangle("fill", ww - w, 0, w, wh)
+ else -- split: a black cross growing out of the centre in both axes
+ local h, w = wh / 2 * prog, ww / 2 * prog
+ love.graphics.rectangle("fill", 0, wh / 2 - h, ww, h * 2)
+ love.graphics.rectangle("fill", ww / 2 - w, 0, w * 2, wh)
+ end
end
love.graphics.setScissor()
love.graphics.setColor(1, 1, 1, 1)
@@ -509,6 +636,50 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
love.graphics.setShader()
end
+-- Take a rect out of a list of rects, splitting each overlapped one into up
+-- to four pieces. Used to vacate an anchored UI region from the letterbox
+-- blit, so the element is drawn at its anchor and not also in place.
+local function subtractRect(list, x, y, w, h)
+ local out = {}
+ local x2, y2 = x + w, y + h
+ for _, r in ipairs(list) do
+ local rx, ry, rw, rh = r[1], r[2], r[3], r[4]
+ local rx2, ry2 = rx + rw, ry + rh
+ if x2 <= rx or x >= rx2 or y2 <= ry or y >= ry2 then
+ out[#out + 1] = r -- disjoint
+ else
+ if ry < y then out[#out + 1] = { rx, ry, rw, y - ry } end
+ if y2 < ry2 then out[#out + 1] = { rx, y2, rw, ry2 - y2 } end
+ local ty, ty2 = math.max(ry, y), math.min(ry2, y2)
+ if rx < x then out[#out + 1] = { rx, ty, x - rx, ty2 - ty } end
+ if x2 < rx2 then out[#out + 1] = { x2, ty, rx2 - x2, ty2 - ty } end
+ end
+ end
+ return out
+end
+
+-- A UI element that should sit against a screen edge rather than inside the
+-- centred letterbox. Declared during the element's own draw, in UI-canvas
+-- pixels, and consumed by endFrame this frame only.
+-- anchor: "bottom" | "topright" | "topleft" | "bottomright"
+function Renderer:setUIAnchor(x, y, w, h, anchor)
+ -- UI LAYOUT = CENTERED (uiCentered, set per frame by Game:draw from
+ -- save.options.uiLayout): every element stays where it was drawn in the
+ -- 160x144 canvas and the letterbox centres the lot, which is how the port
+ -- behaved before edge docking existed. This is the DEFAULT; DYNAMIC opts
+ -- back into docking. Gating here rather than at each caller means one
+ -- switch covers the dialogue box, its YES/NO, the START menu and anything
+ -- added later, and none of them has to know the option exists.
+ if self.uiCentered then return end
+ -- uiAnchorHold (Game:draw): a state that composes its own screen -- a
+ -- battle -- keeps every element inside it, so the box blits where it was
+ -- drawn in the canvas instead of being pulled to the window edge.
+ if self.uiAnchorHold then return end
+ self.uiAnchors = self.uiAnchors or {}
+ self.uiAnchors[#self.uiAnchors + 1] =
+ { x = x, y = y, w = w, h = h, anchor = anchor }
+end
+
-- zones: optional list of SGB palette regions (see PaletteFX) in
-- 160x144 UI space, applied to the UI pass. worldZones: optional
-- regions in world-canvas pixels (overworld survey zoom colors each
@@ -529,6 +700,23 @@ function Renderer:endFrame(zones, worldZones)
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
local oy = math.floor((ph - uih * Sp) / 2) / dpiY
+ -- The UI has its own scale: it steps down as the survey zoom goes out (see
+ -- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
+ -- are identical to Sp/ox/oy and every rect below is what it always was.
+ local Up = self:uiScale()
+ -- BATTLE SIZE "fill" (Renderer.uiFill, set per frame by Game:draw): scale
+ -- the surface to the window rather than to whole GB pixels, so the battle
+ -- fills vertically at any zoom or window size. Fractional by nature -- a
+ -- GB pixel stops being a whole number of screen pixels, which is the trade
+ -- the setting exists to offer. Clamped on the horizontal too, so a narrow
+ -- window scales to fit instead of overflowing off both sides.
+ if self.uiFill then
+ Up = math.min(ph / uih, pw / uiw)
+ end
+ local Ux, Uy = Up / dpiX, Up / dpiY
+ local uvpw, uvph = uiw * Ux, uih * Uy
+ local uox = math.floor((pw - uiw * Up) / 2) / dpiX
+ local uoy = math.floor((ph - uih * Up) / 2) / dpiY
local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -606,7 +794,25 @@ function Renderer:endFrame(zones, worldZones)
local stack = ok and Game and Game.stack
local base = stack and stack.visibleBase and stack:visibleBase()
local state = base and stack.states and stack.states[base]
- if state and state.letterboxWhite then
+ -- A battle owns the surround it established until it leaves the stack.
+ -- Reading it off visibleBase alone loses that the moment the battle opens
+ -- an OPAQUE state -- the party menu, the bag -- because that state becomes
+ -- the base and answers no to letterboxWhite, flipping a white battle
+ -- surround to flat black for as long as the menu is up. Same whole-stack
+ -- hold as uiFill and the dim.
+ for i = #(stack and stack.states or {}), 1, -1 do
+ local s = stack.states[i]
+ if s and s.letterboxWhite then
+ state = s
+ break
+ end
+ end
+ -- BATTLE BG "black" keeps the default black clear; "white" (and any
+ -- non-battle state that opts in) uses the paper shade. "world" never
+ -- reaches here -- it makes the battle non-opaque, so the world pass is
+ -- active and this whole branch is skipped.
+ if state and state.letterboxWhite
+ and not (state.bgMode and state:bgMode() == "black") then
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
end
end
@@ -655,9 +861,6 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1)
end
- if self.battleCascadeProg then
- self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
- end
elseif self.worldActive then
local sp = Zoom.scale(Sp)
local sx, sy = sp / dpiX, sp / dpiY
@@ -732,14 +935,81 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1)
end
- -- Battle transition: cascade black blocks into the area outside the
- -- classic 160x144 wipe square (world still shows through until filled).
- if self.battleCascadeProg then
- self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
+ end
+ -- BATTLE BG "world": the frozen overworld has just been composited and the
+ -- battle is about to blit over it. Dim the world first, so the battle
+ -- reads as the foreground instead of competing with a fully lit map. Goes
+ -- here rather than in the letterbox clear because with the world pass
+ -- active there is no clear -- the world already covers the surface.
+ if self.battleDim and self.battleDim > 0 then
+ love.graphics.setColor(0, 0, 0, self.battleDim)
+ love.graphics.rectangle("fill", 0, 0, ww, wh)
+ love.graphics.setColor(1, 1, 1, 1)
+ end
+
+ -- UI: anchored regions against their screen edges, the rest in the classic
+ -- centred letterbox. With nothing anchored this is the single blit it has
+ -- always been.
+ local anchors = self.uiAnchors
+ if not anchors or #anchors == 0 then
+ blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, uox, uoy, uvpw, uvph)
+ else
+ local rest = { { uox, uoy, uvpw, uvph } }
+ local placed = {}
+ for _, a in ipairs(anchors) do
+ local dw, dh = a.w * Ux, a.h * Uy
+ -- Anchors are edge-RELATIVE: an element keeps its distance from the
+ -- canvas edge, measured against the screen edge instead. That is what
+ -- keeps a stack together -- the yes/no box sits 48px above the canvas
+ -- bottom, so bottom-anchoring lands it 48px above the screen bottom,
+ -- still directly over the dialogue box, rather than on top of it.
+ local gapR = (uiw - (a.x + a.w)) * Ux
+ local gapB = (uih - (a.y + a.h)) * Uy
+ local dx, dy
+ if a.anchor == "bottom" then
+ dx = uox + a.x * Ux -- horizontally it stays with the letterbox
+ dy = wh - gapB - dh
+ elseif a.anchor == "topright" then
+ dx = ww - gapR - dw
+ dy = a.y * Uy
+ else -- unknown anchor: leave it where it is
+ dx, dy = uox + a.x * Ux, uoy + a.y * Uy
+ end
+ placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh }
+ rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh)
+ end
+ for _, r in ipairs(rest) do
+ blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4])
+ end
+ for _, p in ipairs(placed) do
+ -- shift the draw origin so canvas pixel (a.x, a.y) lands on (dx, dy).
+ -- The zone scissors are computed from the same origin, so an SGB
+ -- region travels with the element instead of staying in the letterbox.
+ blit(self.canvas, Ux, Uy, zones, Ux, Uy,
+ p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh)
end
end
- -- UI stays in the classic centered GB letterbox
- blit(self.canvas, Sx, Sy, zones, Sx, Sy, ox, oy, ox, oy, vpw, vph)
+
+ -- The battle wipe covers the whole surface, letterbox included, so it goes
+ -- over the finished composite rather than under the UI blit. On hardware
+ -- it is the tilemap being overwritten -- there is nothing it does not cover.
+ if self.battleWipe then
+ self:drawBattleWipe(self.battleWipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
+ end
+
+ -- Palette-register effects (BattleTransition_FlashScreen's rBGP writes, the
+ -- GBFadeInFromWhite after a battle) tint every pixel the LCD shows -- there
+ -- is no "outside the screen" on hardware for them to miss. So they are
+ -- painted here, over the finished composite, rather than into the 160x144
+ -- UI canvas: at any zoom above 1x a letterbox-only veil left the
+ -- surrounding window untouched, which read as the effect happening inside
+ -- a window rather than to the whole screen. { shade, alpha }.
+ local veil = self.screenVeil
+ if veil and veil[2] > 0 then
+ love.graphics.setColor(veil[1], veil[1], veil[1], veil[2])
+ love.graphics.rectangle("fill", 0, 0, ww, wh)
+ love.graphics.setColor(1, 1, 1, 1)
+ end
if present then
love.graphics.setCanvas()
diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua
index e311d30b..df502512 100644
--- a/src/render/TextBox.lua
+++ b/src/render/TextBox.lua
@@ -8,6 +8,7 @@
local Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
+local Timing = require("src.core.Timing")
local TextBox = {}
TextBox.__index = TextBox
@@ -32,13 +33,22 @@ local MAX_COLS = 18
-- up: StateStack updates the top state only, so the overworld and its
-- ScriptRunner are frozen underneath (the Pewter JIGGLYPUFF dance drives
-- its spin off it, data/scripts/story5.lua, #249).
+-- opts.stay: text that ends in `done` rather than `prompt` returns from
+-- PrintText with the box still on screen while the caller keeps running
+-- (_ViridianSchoolBlackboardText2, data/text/text_2.asm:646). Such a box
+-- waits for nothing, shows no blinking arrow, and never pops itself --
+-- whoever pushed it owns the pop. stay.onShown fires once, on the frame
+-- the last page finishes typing, which is where the caller pushes whatever
+-- goes on top of it (#591).
function TextBox.new(game, text, onDone, opts)
local self = setmetatable({}, TextBox)
self.game = game
self.onDone = onDone
self.choice = opts and opts.choice
self.defaultNo = opts and opts.defaultNo
+ self.choiceNoSound = opts and opts.noSound
self.auto = opts and opts.auto
+ self.stay = opts and opts.stay
local box = Theme.textBox or {}
self.boxTx = box.tx or BOX_TX
self.boxTy = box.ty or BOX_TY
@@ -177,7 +187,24 @@ end
function TextBox:update(dt)
local input = self.game.input
self.blink = (self.blink + 1) % 60
+ -- 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.
+ if (self.holdFrames or 0) > 0 then
+ self.holdFrames = self.holdFrames - 1
+ return
+ end
if self.done then
+ -- opts.stay: the box is finished but stays up under whatever the caller
+ -- pushed over it; StateStack updates the top state only, so this runs
+ -- exactly once (#591)
+ if self.stay then
+ if not self.stayShown then
+ self.stayShown = true
+ if self.stay.onShown then self.stay.onShown() end
+ end
+ return
+ end
if self.auto then
if not self.autoStarted then
self.autoStarted = true
@@ -232,7 +259,9 @@ function TextBox:update(dt)
self.game.stack:push(ChoiceBox.new(self.game, function(yes)
self.game.stack:pop() -- this text box, under the choice
self.choice(yes)
- end, { defaultNo = self.defaultNo }))
+ end, { defaultNo = self.defaultNo, noSound = self.choiceNoSound,
+ -- this box is anchored below it; the pair moves together
+ anchor = "bottom" }))
end
return
end
@@ -244,6 +273,13 @@ function TextBox:update(dt)
return
end
if self.waiting then
+ -- _ContText and Paragraph both print the â–¼ and run ProtectedDelay3
+ -- before ManualTextScroll starts watching the joypad (home/text.asm:265,
+ -- :234), so the arrow is up for three frames that swallow the button.
+ if (self.preWait or 0) > 0 then
+ self.preWait = self.preWait - 1
+ return
+ end
if input:wasPressed("a") or input:wasPressed("b") then
require("src.core.Sound").play(self.game.data, "Press_AB")
self.waiting = false
@@ -252,11 +288,17 @@ function TextBox:update(dt)
self.contAdvance = false
self.lineIndex = self.lineIndex + 1
self:beginLine()
+ -- ScrollTextUpOneLine is 5 blocking frames and, as its own comment
+ -- says, is "always called twice in a row" (home/text.asm:280-305)
+ self.holdFrames = Timing.TEXT_SCROLL_PAIR
else
self.shown = {}
self.pageIndex = self.pageIndex + 1
self.lineIndex = 1
self:beginLine()
+ -- ClearScreenArea then DelayFrames 20: the box sits empty before the
+ -- next page starts typing (home/text.asm:236-240)
+ self.holdFrames = Timing.TEXT_PAGE_CLEAR
end
end
return
@@ -283,6 +325,7 @@ function TextBox:update(dt)
if conts and conts[nextIdx] then
-- pokered : ▼ + WaitForTextScrollButtonPress before scroll
self.waiting = true
+ self.preWait = Timing.TEXT_PRE_ADVANCE
self.contAdvance = true
else
self.lineIndex = nextIdx
@@ -290,6 +333,7 @@ function TextBox:update(dt)
end
elseif self.pageIndex < #self.pages then
self.waiting = true
+ self.preWait = Timing.TEXT_PRE_ADVANCE
self.contAdvance = false
else
self.done = true
@@ -300,6 +344,15 @@ function TextBox:update(dt)
end
function TextBox:draw()
+ -- The dialogue box belongs against the bottom of the screen, not floating
+ -- in the middle of a zoomed-out letterbox. Declared per frame; the
+ -- renderer blits this region to the screen edge and the rest of the UI
+ -- where it always was (Renderer:setUIAnchor).
+ local r = self.game and self.game.renderer
+ if r and r.setUIAnchor then
+ r:setUIAnchor(self.boxTx * 8, self.boxTy * 8,
+ self.boxTw * 8, self.boxTh * 8, "bottom")
+ end
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh)
love.graphics.setColor(0, 0, 0, 1)
if self.scrollPx and self.scrollPx > 0 then
@@ -321,7 +374,8 @@ function TextBox:draw()
Font.drawCode(code, self.textX + (j - 1) * 8, y)
end
end
- if (self.waiting or (self.done and not self.choice and not self.auto))
+ if (self.waiting or (self.done and not self.choice and not self.auto
+ and not self.stay))
and self.blink < 30 then
-- page-advance cursor: glyph $EE by default, the blinking down arrow
-- the original prints via `ld a, "▼"` (home/text.asm)
diff --git a/src/render/Transition.lua b/src/render/Transition.lua
index 9d1b7406..23784ca0 100644
--- a/src/render/Transition.lua
+++ b/src/render/Transition.lua
@@ -1,17 +1,47 @@
-- Screen fade used for warps: fade out, run a callback (map switch), fade in.
-- Pushed on the state stack above the overworld.
+local Timing = require("src.core.Timing")
+
local Transition = {}
Transition.__index = Transition
-local FRAMES = 12
+-- PlayMapChangeSound tail-calls GBFadeOutToBlack on every map change
+-- (home/overworld.asm:703), which is four palette steps of DelayFrames 8 --
+-- 32 frames. There is no matching fade in: the new map is built while the
+-- palettes are still blacked out and OverworldLoop's LoadGBPal
+-- (home/overworld.asm:45) restores them in a single write, so the map pops
+-- in. The old symmetric 12/12 fade was both too fast and a shape the
+-- hardware never had.
+local FRAMES = Timing.WARP_FADE_OUT
+local FRAMES_IN = Timing.WARP_FADE_IN
local FLASH_FRAMES = 7
+-- Every fade in home/fade.asm holds each palette write with
+-- `ld c, 8 / call DelayFrames`, so eight frames is the step of all of them.
+local FADE_STEP_FRAMES = 8
+
+-- Veil alpha `t` frames into a `len`-frame fade out. GBFadeOutToBlack
+-- (home/fade.asm:43-46) walks FadePal4 -> FadePal3 -> FadePal2 -> FadePal1
+-- with b = 4, and FadePal4 IS the normal palette while FadePal1 is solid
+-- black (:64-67) -- so the screen sits untouched through the first eight
+-- frames and then drops a whole shade per step, reaching black for the last
+-- hold. The port tweened the veil instead, which reads as a dissolve rather
+-- than a Game Boy fade (#607). A record retimed shorter than two steps
+-- (mods) keeps the old ramp so it still reaches black.
+local function fadeAlpha(t, len)
+ if not len or len <= 0 then return 1 end
+ local steps = math.floor(len / FADE_STEP_FRAMES)
+ if steps < 2 then return t / len end
+ local step = math.min(steps - 1, math.floor(t / (len / steps)))
+ return step / (steps - 1)
+end
+
-- The two fades as transitions records, so a mod retimes a warp fade the
-- same way it retimes a battle wipe. BattleTransition.registerInto pulls
-- these in with its eight wipes -- one registrant owns the registry.
Transition.STYLES = {
- warp_fade = { kind = "fade", frames = FRAMES },
+ warp_fade = { kind = "fade", frames = FRAMES, framesIn = FRAMES_IN },
white_flash = { kind = "fade", frames = FLASH_FRAMES },
}
@@ -36,27 +66,47 @@ function Transition.new(game, onMidpoint, onDone)
self.onDone = onDone
self.t = 0
self.phase = "out"
- self.frames = styleOf(game, "warp_fade").frames or FRAMES
+ local style = styleOf(game, "warp_fade")
+ self.frames = style.frames or FRAMES
+ -- a style may still ask for a fade in (mods, and the record is data-driven);
+ -- the built-in warp is 0, matching hardware
+ self.framesIn = style.framesIn or FRAMES_IN
return self
end
+function Transition:finish()
+ self.game.stack:pop()
+ if self.onDone then self.onDone() end
+end
+
function Transition:update(dt)
self.t = self.t + 1
- if self.t >= self.frames then
+ local len = (self.phase == "out") and self.frames or self.framesIn
+ if self.t >= len then
self.t = 0
if self.phase == "out" then
self.phase = "in"
if self.onMidpoint then self.onMidpoint() end
+ -- LoadGBPal restores the palettes in one write, so with no fade in the
+ -- map is simply there on the next frame
+ if (self.framesIn or 0) <= 0 then self:finish() end
else
- self.game.stack:pop()
- if self.onDone then self.onDone() end
+ self:finish()
end
end
end
+-- The veil alpha for this frame, exposed the way BattleReturn:alpha is so
+-- the staircase can be asserted without stubbing love.graphics.
+function Transition:alpha()
+ local len = (self.phase == "out") and self.frames or self.framesIn
+ local a = fadeAlpha(self.t, len)
+ if self.phase == "in" then a = 1 - a end
+ return a
+end
+
function Transition:draw()
- local alpha = self.t / self.frames
- if self.phase == "in" then alpha = 1 - alpha end
+ local alpha = self:alpha()
-- Survey zoom draws the overworld into a window-filling world canvas
-- while the UI pass stays the classic 160x144 letterbox. A rect on the
-- UI canvas only darkens that center box (issue #121); when the world
@@ -101,4 +151,67 @@ function WhiteFlash:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144)
end
+-- Coming back to the overworld after a battle.
+--
+-- The battle screen is torn down with the palettes still whited out, the
+-- caller spends `ld c, 10 / call DelayFrames` (home/overworld.asm:351-352),
+-- and then EnterMap sees BIT_BATTLE_OVER_OR_BLACKOUT set and runs
+-- MapEntryAfterBattle (:22, :749-753), which is GBFadeInFromWhite -- three
+-- palette steps of DelayFrames 8, so 24 frames. The port had none of it and
+-- simply cut from the battle to the map.
+--
+-- A dark map takes the other branch: wMapPalOffset is nonzero there, so
+-- MapEntryAfterBattle does a plain LoadGBPal and the map is just there. Pass
+-- opts.instant for that case.
+local BattleReturn = {}
+BattleReturn.__index = BattleReturn
+BattleReturn.isOpaque = false -- the overworld draws underneath
+
+function Transition.battleReturn(game, onDone, opts)
+ opts = opts or {}
+ return setmetatable({
+ game = game, onDone = onDone, t = 0,
+ hold = opts.hold or Timing.POST_BATTLE_RETURN,
+ frames = opts.instant and 0 or (opts.frames or Timing.FADE_IN_FROM_WHITE),
+ }, BattleReturn)
+end
+
+function BattleReturn:update(dt)
+ self.t = self.t + 1
+ if self.t >= self.hold + self.frames then
+ self.game.stack:pop()
+ if self.onDone then self.onDone() end
+ end
+end
+
+-- GBFadeInFromWhite is a palette staircase, not a smooth ramp: GBFadeIncCommon
+-- writes one palette then holds it with `ld c, 8 / call DelayFrames`
+-- (home/fade.asm:30-41), three times over. Stepping the veil the same way
+-- keeps the fade reading like a Game Boy palette fade rather than a tween.
+-- The hold is the same eight frames the warp fade steps on, so both share
+-- FADE_STEP_FRAMES at the top of the file.
+
+function BattleReturn:alpha()
+ if self.t < self.hold then return 1 end
+ if self.frames <= 0 then return 0 end
+ local steps = math.max(1, math.floor(self.frames / FADE_STEP_FRAMES))
+ local step = math.floor((self.t - self.hold) / FADE_STEP_FRAMES)
+ if step >= steps then return 0 end
+ return (steps - step - 1) / steps
+end
+
+function BattleReturn:draw()
+ local a = self:alpha()
+ -- the fade is a palette write, so it covers the whole surface; the
+ -- renderer paints it in screen space (see Renderer.screenVeil)
+ local r = self.game and self.game.renderer
+ if r then
+ r.screenVeil = { 1, a }
+ return
+ end
+ love.graphics.setColor(1, 1, 1, a)
+ love.graphics.rectangle("fill", 0, 0, 160, 144)
+ love.graphics.setColor(1, 1, 1, 1)
+end
+
return Transition
diff --git a/src/script/Commands.lua b/src/script/Commands.lua
index 93ed8869..4f9380dc 100644
--- a/src/script/Commands.lua
+++ b/src/script/Commands.lua
@@ -78,7 +78,7 @@ end
-- play_cry's waitForButton form keeps that cry gate but hands the box back
-- to the A/B path once the cry is over -- see TextBox's opts.auto.wait
-- (#247, #251).
-function Commands.show_text(ctx, textId, subs)
+function Commands.show_text(ctx, textId, subs, extraOpts)
local text = ctx.game.data.text[textId]
if not text and ctx.overworld then
text = ctx.game.data:resolveText(ctx.overworld.map.def.label, textId)
@@ -128,6 +128,12 @@ function Commands.show_text(ctx, textId, subs)
end
end
end
+ if extraOpts then
+ opts = opts or {}
+ for k, v in pairs(extraOpts) do
+ opts[k] = v
+ end
+ end
ctx.game.stack:push(TextBox.new(ctx.game, text, function()
runner:resume()
end, opts))
@@ -139,16 +145,18 @@ function Commands.jump(ctx, target)
end
-- ask [subs]: show text, then a YES/NO box; result lands in
--- ctx.lastCheck. subs are forwarded to show_text's {token} filling.
+-- ctx.lastCheck. The box rides opts.choice (TextBox.lua), so the YES/NO
+-- menu pops up over the still-visible text once it finishes typing, the
+-- same as every hand-written prompt (OverworldController, BattleState,
+-- OakSpeech) -- not a bare ChoiceBox after the text box has already been
+-- cleared with A (DisplayTextID's WaitForTextScrollButtonPress never fires
+-- ahead of a YES/NO box in the original; home/text.asm).
function Commands.ask(ctx, textId, subs)
- Commands.show_text(ctx, textId, subs)
- local ChoiceBox = require("src.ui.ChoiceBox")
local runner = ctx.runner
- ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
+ Commands.show_text(ctx, textId, subs, { choice = function(yes)
ctx.lastCheck = yes
runner:resume()
- end))
- runner:yield()
+ end })
end
function Commands.face_player(ctx)
@@ -281,7 +289,19 @@ function Commands.start_battle(ctx, kind, a, b)
end
runner:resume()
end
- ctx.game.stack:push(battle)
+ -- Every battle enters through the transition wipe, script-driven ones
+ -- included: BattleTransition (engine/battle/battle_transitions.asm:1) runs
+ -- from DoBattleTransitionAndInitBattleVariables for all of them, and
+ -- GetBattleTransitionID_WildOrTrainer picks the style from the battle kind.
+ -- Pushing the BattleState straight onto the stack skipped the wipe
+ -- entirely, so every scripted trainer -- gym leaders, the rival, Giovanni --
+ -- and every scripted wild battle simply cut to the battle screen. The
+ -- trainer-sight path already went through pushBattle; this one did not.
+ if ctx.overworld and ctx.overworld.pushBattle then
+ ctx.overworld:pushBattle(battle)
+ else
+ ctx.game.stack:push(battle)
+ end
runner:yield()
end
@@ -584,13 +604,16 @@ local function askNickname(ctx, mon)
and ctx.game.data.pokemon[mon.species].name)
or mon.species
-- Prefer the real text label; fall back to the BattleState wording.
+ local textId, subs
if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then
- Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name })
+ textId, subs = "_DoYouWantToNicknameText", { RAM = name }
else
- Commands.show_text(ctx, Strings("Do you want to\ngive a nickname\nto %s?", name))
+ textId = Strings("Do you want to\ngive a nickname\nto %s?", name)
end
- local ChoiceBox = require("src.ui.ChoiceBox")
- ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
+ -- opts.choice (TextBox.lua): the YES/NO box rides over the still-visible
+ -- question instead of a bare ChoiceBox popping up after an A press
+ -- already cleared it (same fix as Commands.ask).
+ Commands.show_text(ctx, textId, subs, { choice = function(yes)
if not yes then
ctx.lastCheck = success
runner:resume()
@@ -604,9 +627,7 @@ local function askNickname(ctx, mon)
runner:resume()
end,
})
- end))
- runner:yield()
- ctx.lastCheck = success
+ end })
end
-- give_pokemon : _GivePokemon (engine/events/
@@ -770,7 +791,15 @@ function Commands.old_man_demo(ctx)
local battle = BattleState.newWild(ctx.game, om.species, om.level)
battle:makeOldManDemo()
battle.onFinish = function() runner:resume() end
- ctx.game.stack:push(battle)
+ -- InitWildBattle calls DoBattleTransitionAndInitBattleVariables
+ -- unconditionally (core.asm:6699) -- there is no BATTLE_TYPE_OLD_MAN
+ -- special case -- so the catch tutorial gets the wipe like any other
+ -- wild battle
+ if ctx.overworld and ctx.overworld.pushBattle then
+ ctx.overworld:pushBattle(battle)
+ else
+ ctx.game.stack:push(battle)
+ end
runner:yield()
end
diff --git a/src/ui/BindingsMenu.lua b/src/ui/BindingsMenu.lua
index 5cea86ba..606a5ead 100644
--- a/src/ui/BindingsMenu.lua
+++ b/src/ui/BindingsMenu.lua
@@ -123,8 +123,10 @@ function BindingsMenu:beginCapture(item)
self.pending = nil
self.onKeyPressed = BindingsMenu.captureKey
self.onGamepadPressed = BindingsMenu.capturePad
+ self.onJoystickPressed = BindingsMenu.captureJoy
self.onKeyReleased = BindingsMenu.captureKeyRelease
self.onGamepadReleased = BindingsMenu.capturePadRelease
+ self.onJoystickReleased = BindingsMenu.captureJoyRelease
end
function BindingsMenu:endCapture()
@@ -132,8 +134,10 @@ function BindingsMenu:endCapture()
self.pending = nil
self.onKeyPressed = nil
self.onGamepadPressed = nil
+ self.onJoystickPressed = nil
self.onKeyReleased = nil
self.onGamepadReleased = nil
+ self.onJoystickReleased = nil
end
-- Escape is the capture's way out, so it is never captured: every other
@@ -169,6 +173,27 @@ function BindingsMenu:capturePadRelease(button)
end
end
+-- A stick SDL has no game-controller-database entry for reports plain
+-- button numbers instead of names, so its capture stores "joyN" in the
+-- SAME pad slot every other controller uses (#632). The row's right
+-- column, storeBinding's swap and START's reset-all then need no special
+-- case, and src/core/Input.lua's applyBindings is the only place that has
+-- to decode the name. `raw` keeps the two release hooks apart: p.value is
+-- "joy3" here and an SDL button name there, so neither can claim the
+-- other's release. Game only routes a raw stick to these hooks, so a
+-- recognized pad still records its readable SDL name.
+function BindingsMenu:captureJoy(button)
+ if self.pending then return self:endCapture() end
+ self.pending = { slot = "pad", value = "joy" .. button, raw = true }
+end
+
+function BindingsMenu:captureJoyRelease(button)
+ local p = self.pending
+ if p and p.raw and p.value == "joy" .. button then
+ self:storeBinding("pad", p.value)
+ end
+end
+
function BindingsMenu:storeBinding(slot, value)
local item = self.capture
self:endCapture()
diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua
index ca7462d1..332e90ef 100644
--- a/src/ui/BoxMenu.lua
+++ b/src/ui/BoxMenu.lua
@@ -164,18 +164,18 @@ local function release(game)
local mon = box[list.index]
if not mon then return end
local name = monName(game, mon)
- local ChoiceBox = require("src.ui.ChoiceBox")
game.stack:push(TextBox.new(game,
- Strings("Once released,\n%s is\ngone forever. OK?", name), function()
- game.stack:push(ChoiceBox.new(game, function(yes)
+ Strings("Once released,\n%s is\ngone forever. OK?", name), nil, {
+ defaultNo = true, noSound = true,
+ choice = function(yes)
if not yes then return end
table.remove(box, list.index)
require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game,
Strings("%s was\nreleased outside.\fBye %s!", name, name)))
list:removeCurrent()
- end, { defaultNo = true, noSound = true }))
- end))
+ end,
+ }))
end,
}))
end
@@ -196,16 +196,16 @@ local function changeBox(game)
onChoose = function(item, list)
-- the original asks BEFORE switching ("When you change a #MON
-- BOX, data will be saved. OK?"); declining aborts the change
- local ChoiceBox = require("src.ui.ChoiceBox")
game.stack:push(TextBox.new(game,
- Strings("When you change a\nPOKéMON BOX, data\nwill be saved. OK?"), function()
- game.stack:push(ChoiceBox.new(game, function(yes)
+ Strings("When you change a\nPOKéMON BOX, data\nwill be saved. OK?"), nil, {
+ noSound = true,
+ choice = function(yes)
if not yes then return end
game.save.currentBox = item.value
if game.writeSave then game:writeSave() end
list:close()
- end, { noSound = true }))
- end))
+ end,
+ }))
end,
}))
end
diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua
index 013bbafc..1f9f41f8 100644
--- a/src/ui/ChoiceBox.lua
+++ b/src/ui/ChoiceBox.lua
@@ -3,6 +3,7 @@
local Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
local Strings = require("src.core.Strings")
+local Timing = require("src.core.Timing")
local ChoiceBox = {}
ChoiceBox.__index = ChoiceBox
@@ -15,6 +16,12 @@ function ChoiceBox.new(game, onChoose, opts)
self.index = (opts and opts.defaultNo) and 2 or 1
-- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent
self.noSound = (opts and opts.noSound) or false
+ -- Only a choice box sitting on top of an ANCHORED dialogue box rides the
+ -- anchor with it (TextBox passes it). A bare one -- the battle's switch
+ -- offer, a shop or PC confirm -- belongs over the screen that pushed it,
+ -- and docking it to the window edge instead tears it off that screen by
+ -- however far the letterbox sits from the edge.
+ self.anchor = opts and opts.anchor or nil
local box = Theme.choiceBox
self.tx = (opts and opts.tx) or box.tx
self.ty = (opts and opts.ty) or box.ty
@@ -25,6 +32,19 @@ end
function ChoiceBox:update(dt)
local input = self.game.input
+ -- Both branches of DisplayTwoOptionMenu hold 15 frames with the menu still
+ -- on screen before TwoOptionMenu_RestoreScreenTiles hands control back
+ -- (engine/menus/text_box.asm:322-323, :333-334).
+ if self.pending ~= nil then
+ self.holdFrames = self.holdFrames - 1
+ if self.holdFrames <= 0 then
+ local yes = self.pending
+ self.pending = nil
+ self.game.stack:pop()
+ self.onChoose(yes)
+ end
+ return
+ end
if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("a") then
@@ -32,19 +52,28 @@ function ChoiceBox:update(dt)
if not self.noSound then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
- self.game.stack:pop()
- self.onChoose(self.index == 1)
+ self.pending = (self.index == 1)
+ self.holdFrames = Timing.YES_NO_ANSWER
elseif input:wasPressed("b") then
if not self.noSound then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
- self.game.stack:pop()
- self.onChoose(false)
+ -- .choseSecondMenuItem writes wCurrentMenuItem = 1 before the hold, so
+ -- the cursor visibly snaps to NO for those 15 frames
+ self.index = 2
+ self.pending = false
+ self.holdFrames = Timing.YES_NO_ANSWER
end
end
function ChoiceBox:draw()
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
+ -- rides the same bottom anchor as the dialogue box it sits above, so the
+ -- pair travels together (the anchor keeps each element's gap from the edge)
+ local r = self.anchor and self.game and self.game.renderer
+ if r and r.setUIAnchor then
+ r:setUIAnchor(tx * 8, ty * 8, tw * 8, th * 8, self.anchor)
+ end
Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
diff --git a/src/ui/IntroMovie.lua b/src/ui/IntroMovie.lua
index 2046bcc3..49830221 100644
--- a/src/ui/IntroMovie.lua
+++ b/src/ui/IntroMovie.lua
@@ -33,6 +33,10 @@ local IntroMovie = {}
IntroMovie.__index = IntroMovie
IntroMovie.isOpaque = true
+-- Same as the title screen: full-bleed art, no world behind it, no player zoom
+-- to respect, so fill the window rather than sit at the fixed integer scale.
+function IntroMovie:wantsFillScale() return true end
+
-- SGB intro palettes: the splash uses PalPacket_GameFreakIntro (logo
-- GAMEFREAK, falling star columns RED/VIRIDIAN/BLUEMON), the attract
-- fight PalPacket_NidorinoIntro (PURPLEMON letterbox, BLACK bars)
diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua
index 8b0fd0a9..5a97908a 100644
--- a/src/ui/Menu.lua
+++ b/src/ui/Menu.lua
@@ -50,6 +50,9 @@ function Menu.new(game, items, opts)
-- only menus whose real mask includes PAD_START -- the start menu
-- (engine/menus/draw_start_menu.asm) -- opt in here.
self.startCloses = opts.startCloses or false
+ -- screen-edge anchor for this menu (see Menu:draw); nil keeps it in the
+ -- classic centred letterbox
+ self.anchor = opts.anchor
self.onCancel = opts.onCancel
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its
-- menus silent (home/window.asm HandleMenuInput_)
@@ -104,6 +107,14 @@ function Menu:update(dt)
end
function Menu:draw()
+ -- opts.anchor opts a menu out of the centred letterbox and onto a screen
+ -- edge (the START menu asks for "topright"). Only menus that ask for it
+ -- move; every other menu is placed exactly as before.
+ local r = self.anchor and self.game and self.game.renderer
+ if r and r.setUIAnchor then
+ r:setUIAnchor(self.tx * 8, self.ty * 8,
+ self.tw * 8, self.th * 8, self.anchor)
+ end
Font.drawBox(self.tx, self.ty, self.tw, self.th)
love.graphics.setColor(0, 0, 0, 1)
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua
index a69b590f..9803e12f 100644
--- a/src/ui/OakSpeech.lua
+++ b/src/ui/OakSpeech.lua
@@ -20,6 +20,16 @@ local OakSpeech = {}
OakSpeech.__index = OakSpeech
OakSpeech.isOpaque = true
+-- The speech is a white field with a pic on it, and its dialogue box docks to
+-- the WINDOW's bottom edge (Renderer:setUIAnchor, via TextBox). The white it
+-- fills below is only the 160x144 UI canvas, so once the box moved to the
+-- window edge the two stopped touching: black letterbox showed between the
+-- bottom of Oak's white and the top of the box he is speaking from. Filling
+-- the voids with the paper shade -- the same opt-in a battle uses -- puts the
+-- box back on the field. Not a literal 1,1,1: the canvas is colorized, so
+-- endFrame matches it with PaletteFX.paperShade.
+OakSpeech.letterboxWhite = true
+
-- FadeInIntroPic runs a 6-step palette fade; MovePicLeft wipes the mon
-- sprite in from the right. Both play out before the beat's text prints.
local FADE_FRAMES = 24
@@ -46,6 +56,8 @@ local FALLBACKS = {
_OakSpeechText3 = Strings.source("{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!"),
_IntroducePlayerText = Strings.source("First, what is\nyour name?"),
_IntroduceRivalText = Strings.source("This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?"),
+ _YourNameIsText = Strings.source("Right! So your\nname is {PLAYER}!"),
+ _HisNameIsText = Strings.source("That's right! I\nremember now! His\vname is {RIVAL}!"),
}
local function textOr(game, key)
@@ -152,6 +164,14 @@ function OakSpeech.defaultSteps(speech)
presetsWho = "player",
presetsFallback = { "RED", "ASH", "JACK" },
},
+ {
+ -- oak_speech.asm prints YourNameIsText right after the naming screen
+ -- returns ("Right! So your name is RED!"); the port went straight on
+ -- to the rival and dropped it, in every language.
+ id = "confirm_player_name",
+ kind = "say",
+ textKey = "_YourNameIsText",
+ },
{
id = "ask_rival_name",
kind = "say",
@@ -166,6 +186,12 @@ function OakSpeech.defaultSteps(speech)
presetsWho = "rival",
presetsFallback = { "BLUE", "GARY", "JOHN" },
},
+ {
+ -- HisNameIsText, the rival's counterpart to the confirmation above
+ id = "confirm_rival_name",
+ kind = "say",
+ textKey = "_HisNameIsText",
+ },
{
id = "legend",
kind = "say",
@@ -611,6 +637,15 @@ function OakSpeech:draw()
love.graphics.draw(self.walkSheet, self.walkQuad, 64, 60)
end
if self.shrinkText then
+ -- This is a REPLICA of the dialogue box that just closed, redrawn at
+ -- TextBox's own rect (BOX_TX..BOX_TH = 0,12,20,6) so the last page holds
+ -- while the pic shrinks. The real box rides the bottom anchor, so this
+ -- one has to as well -- otherwise the text visibly jumps up a letterbox
+ -- on the frame the real box is swapped for this copy.
+ local r = self.game and self.game.renderer
+ if r and r.setUIAnchor then
+ r:setUIAnchor(0, 12 * 8, 20 * 8, 6 * 8, "bottom")
+ end
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
for i, line in ipairs(self.shrinkText) do
diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua
index 82cf5b43..0cdc7f36 100644
--- a/src/ui/OptionsMenu.lua
+++ b/src/ui/OptionsMenu.lua
@@ -19,6 +19,7 @@ local TileRenderer = require("src.render.TileRenderer")
local GameSpeed = require("src.core.GameSpeed")
local GameVersion = require("src.core.GameVersion")
local VideoMode = require("src.core.VideoMode")
+local FaithfulRes = require("src.core.FaithfulRes")
local FrameCap = require("src.core.FrameCap")
local Performance = require("src.core.Performance")
local Logger = require("src.core.Logger")
@@ -156,6 +157,57 @@ local function buildRows(game)
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
return true
end },
+ -- FIXED keeps the classic integer-scaled letterbox -- a GB pixel is a
+ -- whole number of screen pixels and the battle is the same size at any
+ -- zoom. FILL scales the battle surface to the window so it fills
+ -- vertically; that needs a fractional scale, so pixels stop being evenly
+ -- sized. Battle only: the overworld is untouched either way.
+ { id = "battleFit", label = Strings("BATTLE SIZE"),
+ value = function(g)
+ return g.save.options.battleFit == "fill" and Strings("FILL")
+ or Strings("FIXED")
+ end,
+ step = function(g)
+ local o = g.save.options
+ o.battleFit = o.battleFit == "fill" and "fixed" or "fill"
+ return true
+ end },
+ -- What sits behind and around the battle. WHITE is the classic paper
+ -- field; BLACK swaps it for black bars; WORLD leaves the frozen overworld
+ -- visible underneath, dimmed (the battle stops being opaque, so the map
+ -- shows through everywhere the battle does not paint).
+ { id = "battleBg", label = Strings("BATTLE BG"),
+ value = function(g)
+ local m = g.save.options.battleBg
+ if m == "black" then return Strings("BLACK") end
+ if m == "world" then return Strings("WORLD") end
+ return Strings("WHITE")
+ end,
+ step = function(g, dir)
+ local o = g.save.options
+ local order = { "white", "black", "world" }
+ local cur = 1
+ for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end
+ o.battleBg = order[(cur - 1 + (dir or 1)) % #order + 1]
+ return true
+ end },
+ -- CENTERED is a fixed letterbox: elements stay inside the 160x144 canvas
+ -- and the UI does not follow the survey zoom, so nothing moves or resizes
+ -- under the player. The composition the port shipped with. DYNAMIC docks
+ -- the dialogue box to the window's bottom edge and the START menu to its
+ -- top right, and steps the UI down with the zoom -- easier to read zoomed
+ -- out, but it moves furniture the original never moved, so it is opt-in.
+ -- BATTLE SIZE is independent of this and works under either.
+ { id = "uiLayout", label = Strings("UI LAYOUT"),
+ value = function(g)
+ return g.save.options.uiLayout == "dynamic" and Strings("DYNAMIC")
+ or Strings("CENTERED")
+ end,
+ step = function(g)
+ local o = g.save.options
+ o.uiLayout = o.uiLayout == "dynamic" and "centered" or "dynamic"
+ return true
+ end },
{ id = "ruleset", label = Strings("RULESET"),
value = function(g) return rulesetName(g) end,
step = function(g, dir)
@@ -298,6 +350,20 @@ local function buildRows(game)
VideoMode.apply(o.videoMode)
return true
end },
+ -- Lock the window to an exact 160x144 multiple, so the surface IS the
+ -- Game Boy screen with no letterbox at all. Sits next to VIDEO MODE
+ -- because it overrides it: holding an exact size means dropping
+ -- fullscreen.
+ { id = "faithfulRes", label = Strings("FAITHFUL RATIO"),
+ value = function(g)
+ return FaithfulRes.label(g.save.options.faithfulRes)
+ end,
+ step = function(g, dir)
+ local o = g.save.options
+ o.faithfulRes = FaithfulRes.cycle(o.faithfulRes, dir)
+ FaithfulRes.apply(o.faithfulRes)
+ return true
+ end },
-- hard render cap (issue #88): bounds the present rate so a
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
-- is fixed-step off dt, so this touches presentation only.
diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua
index 0579d656..09a95f56 100644
--- a/src/ui/PartyMenu.lua
+++ b/src/ui/PartyMenu.lua
@@ -373,9 +373,17 @@ function PartyMenu:update(dt)
or Strings("A blinding FLASH\nlights the area!"), function()
self:close()
-- setDark, not a bare field write: ADVANCED carries the darkness
- -- in a baked atlas, so lighting the cave rebuilds it (#383)
- self.game.stack:push(Transition.whiteFlash(self.game, nil,
- function() ow:setDark(false) end))
+ -- in a baked atlas, so lighting the cave drops every resident map
+ -- and rebakes this one (#383). It runs HERE, before the blink,
+ -- because start_sub_menus.asm .flash clears wMapPalOffset before
+ -- PrintText and blinks last of all: the cave is already lit by the
+ -- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the
+ -- blink's completion instead left that rebuild's whole cost --
+ -- seconds of per-pixel atlas baking on a phone -- on screen as a
+ -- solid white frame with nothing under it, which reads as a
+ -- lockup (#610).
+ ow:setDark(false)
+ self.game.stack:push(Transition.whiteFlash(self.game))
end))
return
elseif action == "surf" then
diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua
index 2fbc1fe8..7dec6aef 100644
--- a/src/ui/StartMenu.lua
+++ b/src/ui/StartMenu.lua
@@ -53,7 +53,6 @@ function StartMenu.new(game)
-- (PrintSaveScreenText)
table.insert(items, { label = Strings("SAVE"), onSelect = function()
local TextBox = require("src.render.TextBox")
- local ChoiceBox = require("src.ui.ChoiceBox")
local badges = require("src.inventory.Badges").count(game.data, game.save)
local owned = 0
for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do
@@ -64,8 +63,8 @@ function StartMenu.new(game)
game.save.player.name or "RED", badges, owned,
math.floor(t / 3600), math.floor(t / 60) % 60)
game.stack:push(TextBox.new(game,
- panel .. Strings("\fWould you like to\nSAVE the game?"), function()
- game.stack:push(ChoiceBox.new(game, function(yes)
+ panel .. Strings("\fWould you like to\nSAVE the game?"), nil, {
+ choice = function(yes)
if not yes then return end
-- "Now saving..." beat before the write (save.asm
-- NowSavingString), then GameSavedText + SFX_SAVE
@@ -75,8 +74,8 @@ function StartMenu.new(game)
game.stack:push(TextBox.new(game,
Strings("%s saved\nthe game!", game.save.player.name or "RED")))
end))
- end))
- end))
+ end,
+ }))
end })
table.insert(items, { label = Strings("OPTION"), onSelect = function()
@@ -105,12 +104,12 @@ function StartMenu.new(game)
-- to the title after a confirm (defaultNo guards accidental quits)
table.insert(items, { label = Strings("QUIT"), onSelect = function()
local TextBox = require("src.render.TextBox")
- local ChoiceBox = require("src.ui.ChoiceBox")
- game.stack:push(TextBox.new(game, Strings("RETURN TO MAIN\nMENU?"), function()
- game.stack:push(ChoiceBox.new(game, function(yes)
+ game.stack:push(TextBox.new(game, Strings("RETURN TO MAIN\nMENU?"), nil, {
+ defaultNo = true,
+ choice = function(yes)
if yes then game:returnToTitle() end
- end, { defaultNo = true }))
- end))
+ end,
+ }))
end })
local hooked = Runtime.call("ui.start_menu.items", sameItems, game, items)
@@ -133,7 +132,12 @@ function StartMenu.new(game)
local rowStep = 2
local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep)
local menu = Menu.new(game, items,
- { tx = 9, ty = 0, tw = 11, maxVisible = maxVisible, startCloses = true })
+ -- the START menu hugs the top-right corner of the SCREEN, not of a
+ -- centred letterbox: at 9,0 x 11 it is already flush with the top and
+ -- right of the 20x18 grid, so the anchor keeps it flush when the view
+ -- is zoomed out and the letterbox no longer fills the window
+ { tx = 9, ty = 0, tw = 11, maxVisible = maxVisible, startCloses = true,
+ anchor = "topright" })
-- the cursor position survives closing the menu
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
menu.index = math.min(game.save.startMenuIndex or 1, #items)
@@ -145,9 +149,16 @@ function StartMenu.new(game)
end
-- inside the Safari Zone the start menu also shows remaining steps and
- -- SAFARI BALLs (PrintSafariZoneSteps): a 9x5 border at the top-left with
- -- "steps/500" and "BALL xx"
- if game.save.safari then
+ -- SAFARI BALLs (PrintSafariZoneSteps, engine/overworld/player_state.asm:
+ -- 219-224): a 9x5 border at the top-left with "steps/500" and "BALL xx".
+ -- It opens with `cp SAFARI_ZONE_EAST / ret c`, so only the nine interior
+ -- maps ($D9..$E1) get it -- SAFARI_ZONE_GATE is $9C and falls under that
+ -- early out, and used to show "502/500" while the player was still
+ -- standing at the counter (#540). Same map set as the step counter's
+ -- (FieldDefaults safari.stepMaps via OverworldState:inSafariStepZone).
+ local ow = game.overworld
+ if game.save.safari and ow and ow.map and ow.inSafariStepZone
+ and ow:inSafariStepZone() then
local baseDraw = menu.draw
menu.draw = function(self)
baseDraw(self)
diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua
index c7e7e361..6ff9f8c8 100644
--- a/src/ui/SummaryMenu.lua
+++ b/src/ui/SummaryMenu.lua
@@ -203,7 +203,8 @@ function SummaryMenu:draw()
local mdef = data.moves[mv.id]
Font.draw(mdef.name, 16, y)
Font.draw(Strings("PP"), 88, y + 8)
- Font.draw(("%2d/%2d"):format(mv.pp, mdef.pp), 112, y + 8)
+ local maxPP = mdef.pp + (mv.ppUps or 0) * math.floor(mdef.pp / 5)
+ Font.draw(("%2d/%2d"):format(mv.pp, maxPP), 112, y + 8)
else
Font.draw("-", 16, y)
Font.draw("--", 112, y + 8)
diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua
index 2d672033..9f293f42 100644
--- a/src/ui/TitleState.lua
+++ b/src/ui/TitleState.lua
@@ -14,6 +14,12 @@ local TitleState = {}
TitleState.__index = TitleState
TitleState.isOpaque = true
+-- Fill the window (aspect preserved, bars on the long axis) instead of sitting
+-- at the fixed integer scale. The title screen is a full-bleed picture with no
+-- world behind it, so a small centred box in a large window is just wasted
+-- glass -- and unlike the overworld it has no zoom the player chose to respect.
+function TitleState:wantsFillScale() return true end
+
-- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2,
-- the version-ribbon band LOGO1, the rest MEWMON.
--
diff --git a/src/ui/TouchControlsEditor.lua b/src/ui/TouchControlsEditor.lua
index 73f54bed..6d088189 100644
--- a/src/ui/TouchControlsEditor.lua
+++ b/src/ui/TouchControlsEditor.lua
@@ -1,8 +1,13 @@
-- Launcher touch-controls editor (#327): drag each on-screen button to a
--- new spot, toggle the overlay off entirely, Reset to defaults, Done to
--- persist into options.lua. Opened from the game panel's "Touch Controls"
--- button; main.lua suspends the launcher the same way it does for the
--- save editor.
+-- new spot, resize them, toggle the overlay off entirely, Reset to
+-- defaults, Done to persist into options.lua. Opened from the game
+-- panel's "Touch Controls" button; main.lua suspends the launcher the same
+-- way it does for the save editor.
+--
+-- Everything edited here belongs to the orientation currently on screen
+-- (#633): portrait and landscape keep separate positions and sizes, so a
+-- player lays out each rotation once. Rotate the device (or resize the
+-- desktop window past square) and the editor switches buckets live.
--
-- Draws in full window LOVE units -- the same space TouchControls uses
-- after Renderer:endFrame -- so what you drag here is what you get in play.
@@ -63,9 +68,11 @@ end
local function persist()
local opts = SaveData.loadOptions()
local cfg = TouchControls:config()
+ -- replaces the whole table, so a pre-#633 top-level positions key is
+ -- dropped once the player saves under the new shape
opts.touchControls = {
enabled = cfg.enabled,
- positions = cfg.positions,
+ layouts = cfg.layouts,
}
SaveData.saveOptions(opts)
end
@@ -113,6 +120,8 @@ function Editor.draw()
local fullW, fullH = love.graphics.getDimensions()
local ox, oy, ww, wh = SafeArea.rect()
local s = math.max(0.75, math.min(1.4, wh / 768))
+ -- resolve the orientation bucket before any chrome reads scale (#633)
+ local bucket = TouchControls:currentBucket()
Editor.rects = {}
-- radial-ish navy field (two stacked fills; matches launcher atmosphere)
@@ -186,13 +195,41 @@ function Editor.draw()
chromeBtn(toggle, on and "Disable" or "Enable",
on and PAL.red or PAL.green)
+ -- size card (#633): -/+ resize every control in the orientation on
+ -- screen; the heading names it so it is plain the other one is untouched
+ local sizeY = cardY + cardH + 10 * s
+ col(PAL.card, 0.88)
+ roundRect("fill", cardX, sizeY, cardW, cardH, 12 * s)
+ col(PAL.stroke, 0.4)
+ roundRect("line", cardX, sizeY, cardW, cardH, 12 * s)
+
+ love.graphics.setFont(Editor.fonts.body)
+ col(PAL.label)
+ local orient = TouchControls.orientation == "landscape"
+ and "Landscape" or "Portrait"
+ love.graphics.print("Button size (" .. orient .. ")",
+ cardX + 16 * s, sizeY + 12 * s)
+ love.graphics.setFont(Editor.fonts.btn)
+ col(PAL.white)
+ love.graphics.print(
+ string.format("%d%%", math.floor((bucket.scale or 1) * 100 + 0.5)),
+ cardX + 16 * s, sizeY + 34 * s)
+
+ local stepW = 52 * s
+ local plus = { x = cardX + cardW - 16 * s - stepW,
+ y = sizeY + (cardH - btnH) / 2, w = stepW, h = btnH }
+ local minus = { x = plus.x - 10 * s - stepW, y = plus.y, w = stepW, h = btnH }
+ Editor.rects.sizeUp, Editor.rects.sizeDown = plus, minus
+ chromeBtn(minus, "-", { 60, 70, 110 })
+ chromeBtn(plus, "+", { 60, 70, 110 })
+
-- hint
love.graphics.setFont(Editor.fonts.body)
col(PAL.label, 0.9)
local hint = on
- and "Drag each button to reposition. Layout is saved when you tap Done."
+ and "Drag each button to reposition, -/+ to resize. Portrait and landscape are saved separately when you tap Done."
or "Controls are hidden in-game. Enable them to show and edit the layout."
- love.graphics.printf(hint, ox + pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
+ love.graphics.printf(hint, ox + pad, sizeY + cardH + 12 * s, ww - 2 * pad, "left")
-- the overlay itself (preview mode; dimmed when disabled)
TouchControls:draw()
@@ -214,6 +251,12 @@ local function beginDrag(id, x, y)
if inside(Editor.rects.done, x, y) then close(); return end
if inside(Editor.rects.reset, x, y) then resetLayout(); return end
if inside(Editor.rects.toggle, x, y) then toggleEnabled(); return end
+ if inside(Editor.rects.sizeDown, x, y) then
+ TouchControls:nudgeScale(-TouchControls.SCALE_STEP); return
+ end
+ if inside(Editor.rects.sizeUp, x, y) then
+ TouchControls:nudgeScale(TouchControls.SCALE_STEP); return
+ end
local name = TouchControls:hitTest(x, y)
if not name then return end
@@ -271,6 +314,11 @@ function Editor.keypressed(key)
close()
elseif key == "r" then
resetLayout()
+ -- desktop shortcuts for the size -/+ (POKEPORT_TOUCH=1 testing, #633)
+ elseif key == "-" or key == "kp-" then
+ TouchControls:nudgeScale(-TouchControls.SCALE_STEP)
+ elseif key == "=" or key == "+" or key == "kp+" then
+ TouchControls:nudgeScale(TouchControls.SCALE_STEP)
end
end
diff --git a/src/world/FieldDefaults.lua b/src/world/FieldDefaults.lua
index 00b038d5..781495e8 100644
--- a/src/world/FieldDefaults.lua
+++ b/src/world/FieldDefaults.lua
@@ -77,6 +77,12 @@ local SAFARI = {
local PLAYER_SPRITES = {
walk = "SPRITE_RED", surf = "SPRITE_SEEL",
bike = "SPRITE_RED_BIKE", fly = "SPRITE_BIRD",
+ -- Yellow's IsSurfingPikachuInParty: when the SURF-mon is a Pikachu,
+ -- the player rides this sheet. GFX loads via
+ -- LoadSurfingPlayerSpriteGraphics2, not SpriteSheetPointerTable, so
+ -- it needs a manifest extract (RFC 0001). Guarded in Player.new so
+ -- before extraction lands the ride keeps the Seel.
+ surfPikachu = "SPRITE_SURFING_PIKACHU",
}
-- The player's own trainer art: RedPicBack (the battle back pic, up until
diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua
index 394b9507..a6bf7d50 100644
--- a/src/world/OverworldController.lua
+++ b/src/world/OverworldController.lua
@@ -389,6 +389,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
and not self.map:isWalkableCell(x, y)
and self.map:isWaterCell(x, y)
end
+ -- re-derive from the live party: a reloaded save with the SURF-Pikachu
+ -- since deposited should not render the Pikachu sheet.
+ -- ponytail: re-derived rather than persisted.
+ self:syncSurfingPikachu()
end
-- crossConnection re-arms this after setMap; clear so a warp/reload
-- cannot leave a stale deferred PlayMapMusic pending
@@ -717,6 +721,11 @@ function OverworldState:pushBattle(battle)
if battle.computeMusicKind then
require("src.core.Music").playBattle(Game.data, battle:computeMusicKind())
end
+
+ -- The fade back in from white on the way out is BattleState:finish()'s
+ -- job now -- the one choke point every battle passes through on exit,
+ -- guaranteed regardless of which caller pushed the battle -- so this
+ -- function only owns the entry wipe.
Game.stack:push(BattleTransition.new(Game, function()
Game.stack:push(battle)
end, {
@@ -1484,6 +1493,21 @@ function OverworldState:partyKnows(moveId)
return partyKnowsVanilla(moveId)
end
+-- IsSurfingPikachuInParty (home/map_objects.asm): when the SURF-mon
+-- is a Pikachu, pose() renders the Pikachu surf sprite. Called at
+-- every surf-state change so a reloaded save picks the right sheet
+-- after a party change. No-op when not surfing.
+function OverworldState:syncSurfingPikachu()
+ local p = self.player
+ if not p then return end
+ if not p.surfing then
+ p.surfingPikachu = false
+ return
+ end
+ local mon = self:partyKnows("SURF")
+ p.surfingPikachu = mon ~= nil and mon.species == "PIKACHU" or false
+end
+
-- The rejection loop shared by the Good and Super Rods
-- (item_effects.asm ItemUseGoodRod .RandomLoop / ReadSuperRodData): an
-- odd random byte is no bite; otherwise a 2-bit pick rerolls until it
@@ -1580,6 +1604,7 @@ function OverworldState:flyTo(mapId)
Game.save.onBike = false
Game.save.forcedBike = nil -- HandleFlyWarpOrDungeonWarp res BIT_ALWAYS_ON_BIKE
self.player.surfing = false
+ self:syncSurfingPikachu()
-- the bird carries the player off westward before the warp
-- (engine/overworld/player_animations.asm LoadBirdSpriteGraphics)
self.flyAnim = { frames = 48 }
@@ -1607,6 +1632,7 @@ function OverworldState:beginTeleportOut(onDone)
end
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
self.player.surfing = false
+ self:syncSurfingPikachu()
self.player.inputLocked = true
-- rising spin: the mirror of the arrival spin-drop set in startWarpTo, so
-- spinRise lifts the sprite (Player:pose) while spinFrames counts down
@@ -2270,6 +2296,7 @@ function OverworldState:trySurf(fx, fy, onClose)
Game.stack:push(TextBox.new(Game, text, function()
if onClose then onClose() end
p.surfing = true
+ self:syncSurfingPikachu()
require("src.core.Music").setSurfing(Game.data, true)
Game.stack:push(require("src.render.Transition").whiteFlash(Game, nil,
function() self:stepForwardOrCrossEdge(p.facing) end))
@@ -2553,8 +2580,13 @@ function OverworldState:openPC(onDone)
-- (engine/menus/pokemon_pc.asm gates on EVENT_MET_BILL; we reach that
-- when Bill hands over the SS Ticket)
local metBill = flags.EVENT_MET_BILL or flags.EVENT_GOT_SS_TICKET
+ -- keepOpen so B in the sub-PC returns here instead of exiting the
+ -- PC session (#695); the sub-PC screens (BoxMenu, PlayerPC) already
+ -- use keepOpen for their own rows, matching the original ROM's flow
+ -- where the main menu stays underneath.
table.insert(items, {
label = metBill and "BILL'S PC" or Strings("SOMEONE'S PC"),
+ keepOpen = true,
onSelect = function()
require("src.core.Sound").play(Game.data, "Enter_PC")
Screens.push(Game, "BoxMenu")
@@ -2565,6 +2597,7 @@ function OverworldState:openPC(onDone)
-- the player's item storage is always available
table.insert(items, {
label = (Game.save.player.name or "RED") .. "'s PC",
+ keepOpen = true,
onSelect = function()
Screens.push(Game, "PlayerPC")
done()
@@ -2575,6 +2608,7 @@ function OverworldState:openPC(onDone)
if flags.EVENT_GOT_POKEDEX then
table.insert(items, {
label = Strings("PROF.OAK's PC"),
+ keepOpen = true,
onSelect = function()
self:openOaksPC(done)
end,
@@ -3191,6 +3225,7 @@ function OverworldState:onStepComplete()
-- dismounting a surf: landing on a walkable cell ends it
if p.surfing and self.map:isWalkableCell(p.cellX, p.cellY) then
p.surfing = false
+ self:syncSurfingPikachu()
require("src.core.Music").setSurfing(Game.data, false)
end
@@ -3492,6 +3527,7 @@ function OverworldState:checkForcedMovement()
end
elseif tile.mode == "surf" then
p.surfing = true
+ self:syncSurfingPikachu()
require("src.core.Music").setSurfing(Game.data, true)
end
return false
@@ -3745,7 +3781,9 @@ function OverworldState:takeWarp(warpDef)
self:startWarpTo(destMap, x, y, facing)
return
elseif pad == "hole" then
- -- falling through a hole: no door SFX, no walk-out step
+ -- falling through a hole: Faint_Fall plays while the player drops,
+ -- matching the boulder-hole Faint_Thud at line 3618 (#694)
+ require("src.core.Sound").play(Game.data, "Faint_Fall")
self:startWarpTo(destMap, x, y, facing)
return
end
@@ -3770,6 +3808,7 @@ end
function OverworldState:warpToHealPoint(onDone, opts)
local heal = self:healPoint()
self.player.surfing = false
+ self:syncSurfingPikachu()
-- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear
-- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm)
Game.save.forcedBike = nil
diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua
index c85323a7..708f386b 100644
--- a/src/world/PikachuFollower.lua
+++ b/src/world/PikachuFollower.lua
@@ -789,6 +789,14 @@ function PikachuFollower.onBillsHouseEnter(game, ow)
return
end
if game.save.flags.EVENT_MET_BILL_2 then return end
+ -- BillsHouseScript0 (scripts/BillsHouse.asm:41-47) only runs the confused
+ -- walk while CheckPikachuStatusCondition comes back clear
+ -- (engine/pikachu/pikachu_status.asm:140, carry when the starter's status
+ -- byte is nonzero); a statused starter keeps trailing the player instead,
+ -- and that is the one state that lets onBillWalksAroundPlayer below fire,
+ -- since the confused beat ends in DisablePikachuFollowingPlayer (#455).
+ local starter = PikachuFollower.starterInParty(game.save)
+ if starter and starter.status then return end
local npc = findFollower(ow)
if not npc then return end
ow.pikachuBillsScene = true
@@ -797,6 +805,46 @@ function PikachuFollower.onBillsHouseEnter(game, ow)
end)
end
+-- BillsHousePikachuWatchPlayer (scripts/BillsHouse_2.asm:133-156), reached
+-- from BillsHouseScript2 (scripts/BillsHouse.asm:58-69) only when the player
+-- faces down -- so Bill has to walk around them -- and Pikachu is still
+-- following, i.e. the confused beat above was skipped. Both tables go
+-- through TryApplyPikachuMovementData
+-- (engine/events/try_pikachu_movement.asm:1-15), which keeps the data only
+-- when the caller's b equals GetPikachuFacingDirectionAndReturnToE
+-- (engine/pikachu/pikachu_follow.asm:1110-1151). That byte is not the
+-- sprite's facing: it is Pikachu's position relative to the player, UP when
+-- Pikachu's MapY sits above the player's, DOWN below, and on a shared row
+-- LEFT when west, RIGHT when east ($ff on the same cell). So WatchPlayer1
+-- (b = SPRITE_FACING_UP) means "Pikachu stands above the player" and
+-- WatchPlayer2 (b = SPRITE_FACING_RIGHT) means "Pikachu stands level with
+-- and east of the player". ApplyPikachuMovementData_ walks a whole table
+-- synchronously (engine/pikachu/pikachu_movement.asm:9-18), so the second
+-- call does observe the finished walk; the two are mutually exclusive by
+-- geometry rather than by timing, since both routes end at
+-- (playerX - 1, playerY), west of the player, where the RIGHT test fails.
+-- Each closes on PIKAMOVEMENT_LOOK_RIGHT, leaving Pikachu watching the
+-- player while Bill talks; PIKAMOVEMENT_DELAY is dropped here as in the
+-- beats above (#455).
+function PikachuFollower.onBillWalksAroundPlayer(game, ow)
+ if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE")
+ or ow.pikachuBillsScene then
+ return
+ end
+ local npc = findFollower(ow)
+ if not npc or not ow.player then return end
+ local steps
+ if npc.cellY < ow.player.cellY then -- PikachuMovement_WatchPlayer1
+ steps = { { "left", 1 }, { "down", 1 } }
+ elseif npc.cellY == ow.player.cellY
+ and npc.cellX > ow.player.cellX then -- PikachuMovement_WatchPlayer2
+ steps = { { "up", 1 }, { "left", 2 }, { "down", 1 } }
+ else
+ return
+ end
+ movePikachu(ow, npc, steps, function() npc.facing = "right" end)
+end
+
function PikachuFollower.onBillEnteredMachine(game, ow)
if not (GameVersion.isYellow() and ow.pikachuBillsScene) then return end
local npc = findFollower(ow)
diff --git a/src/world/Player.lua b/src/world/Player.lua
index e1a206f3..5fb257e8 100644
--- a/src/world/Player.lua
+++ b/src/world/Player.lua
@@ -41,11 +41,18 @@ function Player.new(data, cx, cy, facing)
-- LoadSurfingPlayerSpriteGraphics, home/overworld.asm)
local walkId = FieldDefaults.fieldValue(data, "playerSprites", "walk")
local surfId = FieldDefaults.fieldValue(data, "playerSprites", "surf")
+ local surfPikaId = FieldDefaults.fieldValue(data, "playerSprites", "surfPikachu")
local bikeId = FieldDefaults.fieldValue(data, "playerSprites", "bike")
self.sprite = SpriteRenderer.new(data.sprites[walkId], "player")
if surfId and data.sprites[surfId] then
self.surfSprite = SpriteRenderer.new(data.sprites[surfId], "player")
end
+ -- Yellow's surfing-Pikachu ride (Yellow LoadSurfingPlayerSpriteGraphics2,
+ -- paired with field.playerSprites.surfPikachu). rotated in at pose()
+ -- when the SURF-mon is a Pikachu.
+ if surfPikaId and data.sprites[surfPikaId] then
+ self.surfPikachuSprite = SpriteRenderer.new(data.sprites[surfPikaId], "player")
+ end
if bikeId and data.sprites[bikeId] then
self.bikeSprite = SpriteRenderer.new(data.sprites[bikeId], "player")
end
@@ -288,6 +295,7 @@ function Player:pose()
-- RodResponse (engine/items/item_effects.asm) zeroes wWalkBikeSurfState
-- across FishingAnim, so casting from the water shows the on-foot sheet
local sprite = (self.fishing and self.sprite)
+ or (self.surfing and self.surfingPikachu and self.surfPikachuSprite)
or (self.surfing and self.surfSprite)
or (self.onBike and self.bikeSprite) or self.sprite
return sprite, self.px, py, facing, phase, flip, hopping
diff --git a/tests/drivers/audio_channels_bug626_test.lua b/tests/drivers/audio_channels_bug626_test.lua
new file mode 100644
index 00000000..8dd01857
--- /dev/null
+++ b/tests/drivers/audio_channels_bug626_test.lua
@@ -0,0 +1,136 @@
+-- Manual check that audio stays on the front output pair on interfaces with
+-- more than two outputs (#626). OpenAL only spatializes 1-channel Sources,
+-- and one left at the default (0,0,0) position sits on the listener and is
+-- spread over every output a device has; the fix renders one-shots as
+-- 2-channel buffers so they map onto outputs 1+2 like the music already did.
+-- Positions come from pokered data/maps/objects/PalletTown.asm (warps at
+-- (5,5), (13,5), (12,11), kept clear of) plus data/generated/maps.lua for the
+-- walkable/blocked cells. Channel counts a machine can check; the routing
+-- only an ear on a multi-output interface can, hence the hand-off.
+-- Do NOT set POKEPORT_SPEED: fast-forward scales only the logic clock and
+-- desyncs the audio ordering this driver depends on.
+-- POKEPORT_DRIVER=tests/drivers/audio_channels_bug626_test.lua POKEPORT_IDENTITY=bug626 POKEPORT_TOUCH=0 love .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Sound = require("src.core.Sound")
+ local Music = require("src.core.Music")
+ local ChipAudio = require("src.core.ChipAudio")
+
+ -- pokered data/maps/objects/PalletTown.asm: the three warps sit at (5,5),
+ -- (13,5) and (12,11), so (10,8) is open ground clear of all of them and of
+ -- the two walking NPCs' start cells ((3,8) girl, (11,14) fisher).
+ local MAP = "PALLET_TOWN"
+ local STAND = { x = 10, y = 8, facing = "down" }
+
+ local function check(label, ok)
+ U.log(ok and "PASS" or "FAIL", label)
+ return ok
+ end
+
+ local function channels(src)
+ if not src then return nil end
+ local ok, n = pcall(function() return src:getChannelCount() end)
+ return ok and n or nil
+ end
+
+ if not love.audio then
+ check("love.audio is available", false)
+ U.log("This run has no audio device, so nothing below can be judged.")
+ U.log("Rerun on a desktop love build with the interface connected.")
+ while true do coroutine.yield() end
+ end
+ check("love.audio is available", true)
+
+ -- a muted save reads exactly like the bug being fixed: silence everywhere
+ local opts = game.save and game.save.options or {}
+ check("sfxVol is not zero (" .. tostring(opts.sfxVol or 7) .. ")",
+ (opts.sfxVol or 7) ~= 0)
+ check("musicVol is not zero (" .. tostring(opts.musicVol or 7) .. ")",
+ (opts.musicVol or 7) ~= 0)
+
+ -- an unknown key makes Sound.play return nil, which would read as a channel
+ -- FAIL for the wrong reason
+ local sfxTable = game.data.audio and game.data.audio.sfx or {}
+ local cryTable = game.data.audio and game.data.audio.cries or {}
+ local songTable = game.data.audio and game.data.audio.songs or {}
+ check("sfx key Press_AB is in this cache", sfxTable.Press_AB ~= nil)
+ check("sfx key Collision is in this cache", sfxTable.Collision ~= nil)
+ check("cry PIKACHU is in this cache", cryTable.PIKACHU ~= nil)
+ check("song Music_PalletTown is in this cache",
+ songTable.Music_PalletTown ~= nil)
+
+ -- the machine-checkable half of the fix: every one-shot is a stereo buffer
+ local sfx = Sound.play(game.data, "Press_AB")
+ check("menu beep source is stereo", channels(sfx) == 2)
+ U.wait(30)
+
+ local cry = Sound.playCry(game.data, "PIKACHU")
+ check("cry source is stereo", channels(cry) == 2)
+ U.wait(60)
+
+ -- the siren is built by hand in ChipAudio, not through ChipSynth, so it is
+ -- its own path and its own regression risk
+ local ok, alarm = pcall(ChipAudio.newLowHealthAlarm)
+ check("low-health siren is stereo", ok and channels(alarm) == 2)
+ if ok and alarm and alarm.stop then pcall(alarm.stop, alarm) end
+
+ -- reach the moment: overworld with the town theme running and a beep fired
+ U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
+ U.wait(10)
+ Music.play(game.data, "Music_PalletTown", true)
+ U.wait(60)
+
+ local ow = game.overworld
+ check("overworld is up on " .. MAP, ow ~= nil)
+
+ -- bump a wall so the collision beep (OverworldController: Sound.play
+ -- "Collision") sounds at least once before the hand-off. The blocked
+ -- neighbour is looked up rather than hardcoded, so a map edit degrades to
+ -- "walked a step" instead of a silent no-op.
+ local bumped = false
+ if ow and ow.map then
+ local dirs = {
+ { 0, -1, "up" }, { 0, 1, "down" }, { -1, 0, "left" }, { 1, 0, "right" },
+ }
+ for _, d in ipairs(dirs) do
+ local cx, cy = ow.player.cellX + d[1], ow.player.cellY + d[2]
+ if not ow.map:isWalkableCell(cx, cy) then
+ U.hold(game, d[3], 24)
+ U.wait(20)
+ bumped = true
+ U.log("walked into the wall at", cx, cy, "facing", d[3])
+ break
+ end
+ end
+ if not bumped then
+ -- open ground on all four sides: walk a step instead, the human can
+ -- find a wall themselves in a moment
+ U.hold(game, "down", 24)
+ U.wait(20)
+ U.log("no wall next to (" .. STAND.x .. ", " .. STAND.y ..
+ "), took a step instead; bump one by hand")
+ end
+ end
+ check("a collision beep was fired before hand-off", bumped)
+
+ U.tap(game, "start")
+ U.wait(40)
+ U.tap(game, "b")
+ U.wait(20)
+
+ U.log("Pallet Town is playing and you have the pad; bump walls and open")
+ U.log("START to fire beeps, and press a cry with the PC or party screens.")
+ U.log("On a multi-output interface the music and every beep and cry should")
+ U.log("come out of outputs 1 and 2 only, with 3/4 and 5/6 dead silent.")
+ U.log("The near miss to listen for is an SFX still faintly there on 5+6")
+ U.log("under the music: that is the old ambient spread and means a one-shot")
+ U.log("path was missed, a mod file def or a Yellow PCM clip or the siren.")
+ U.log("Also listen for a beep arriving on output 3 alone, which would mean")
+ U.log("something positioned a mono source instead of widening it to stereo.")
+ U.log("Selecting outputs 5+6 in macOS will not move the game there either")
+ U.log("way; that is device-level routing, not something the app can ask for.")
+
+ while true do
+ coroutine.yield()
+ end
+end
diff --git a/tests/drivers/bills_pikachu_watch_bug455_test.lua b/tests/drivers/bills_pikachu_watch_bug455_test.lua
new file mode 100644
index 00000000..9f54544a
--- /dev/null
+++ b/tests/drivers/bills_pikachu_watch_bug455_test.lua
@@ -0,0 +1,274 @@
+-- Manual check that Yellow's Pikachu watches the player while Bill walks
+-- around them in Bill's House (#455). BillsHouseScript2 (pokeyellow
+-- scripts/BillsHouse.asm:58-69) only reaches BillsHousePikachuWatchPlayer
+-- (scripts/BillsHouse_2.asm:133-156) while Pikachu still follows, which needs
+-- BillsHouseScript0's CheckPikachuStatusCondition to have skipped the entry
+-- beat, so this driver arrives with a statused starter. No POKEPORT_SPEED:
+-- it scales the logic clock only and desyncs audio against the scripted walk.
+-- POKEPORT_DRIVER=tests/drivers/bills_pikachu_watch_bug455_test.lua POKEPORT_IDENTITY=bug455 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local GameVersion = require("src.core.GameVersion")
+ local Pokemon = require("src.pokemon.Pokemon")
+ local PikachuFollower = require("src.world.PikachuFollower")
+
+ local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
+
+ local function check(label, ok)
+ U.log(ok and "PASS" or "FAIL", label)
+ return ok
+ end
+
+ local function idle()
+ while true do coroutine.yield() end
+ end
+
+ -- Red and Blue have no follower and no Pikachu beats at all, so every line
+ -- below would fail for the wrong reason on the wrong cache
+ if not check("running the Yellow cache (POKEPORT_VERSION=yellow)",
+ GameVersion.isYellow()) then
+ U.log("Re-run with POKEPORT_VERSION=yellow.")
+ idle()
+ end
+
+ -- pokeyellow data/maps/objects/BillsHouse.asm: BILLSHOUSE_BILL_POKEMON sits
+ -- at (6,5), the two warps at (2,7) and (3,7). Standing at (6,4) facing down
+ -- both faces the monster and puts the player on Bill's straight path up, the
+ -- one branch that runs the walk-around (and so the watch beat).
+ local MAP = "BILLS_HOUSE"
+ local MONSTER = "BILLSHOUSE_BILL_POKEMON"
+ local STAND = { x = 6, y = 4, facing = "down" }
+
+ -- the scripted rows and the port entry point the scene hangs off; a renamed
+ -- key reads on screen as "talking to Bill does nothing special"
+ local story = dofile("data/scripts/story.lua")
+ local house = story.BILLS_HOUSE or {}
+ check("story.BILLS_HOUSE.talk.TEXT_BILLSHOUSE_BILL_POKEMON is present",
+ type((house.talk or {}).TEXT_BILLSHOUSE_BILL_POKEMON) == "function")
+ check("story.BILLS_HOUSE.onEnter is present",
+ type(house.onEnter) == "function")
+ check("PikachuFollower.onBillWalksAroundPlayer exists",
+ type(PikachuFollower.onBillWalksAroundPlayer) == "function")
+
+ -- both boxes of the scene come out of the cache; a missing key falls back to
+ -- a hardcoded string in story.lua, which still runs but is not what shipped
+ local t = game.data.text or {}
+ check("_BillsHouseBillImNotAPokemonText resolved from the cache",
+ type(t._BillsHouseBillImNotAPokemonText) == "string")
+ check("_BillsHouseBillUseSeparationSystemText resolved from the cache",
+ type(t._BillsHouseBillUseSeparationSystemText) == "string")
+
+ -- ShouldPikachuSpawn's inputs (pikachu_follow.asm) plus the one that gates
+ -- this bug: a starter carrying a status byte, which is what makes
+ -- CheckPikachuStatusCondition set carry and BillsHouseScript0 stand down
+ local pika = Pokemon.new(game.data, "PIKACHU", 12)
+ pika.status = "PAR"
+ game.save.party = { pika }
+ game.save.player.name = "bryan"
+ game.save.flags = game.save.flags or {}
+ game.save.flags.EVENT_GOT_STARTER = true
+ game.save.flags.EVENT_MET_BILL = nil
+ game.save.flags.EVENT_MET_BILL_2 = nil
+ game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil
+ game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = nil
+ game.save.flags.EVENT_GOT_SS_TICKET = nil
+ game.save.flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = nil
+
+ U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
+ U.wait(20)
+
+ local ow = game.overworld
+ if not check("Bill's House loaded", ow ~= nil and ow.map
+ and ow.map.id == MAP) then
+ idle()
+ end
+
+ local function follower()
+ for _, n in ipairs(ow.npcs or {}) do
+ if n.pikachuFollower then return n end
+ end
+ return nil
+ end
+
+ local function monster()
+ for _, n in ipairs(ow.npcs or {}) do
+ if n.def and n.def.name == MONSTER then return n end
+ end
+ return nil
+ end
+
+ local npc = follower()
+ if not check("follower spawned with pikachuFollower set", npc ~= nil) then
+ U.log("party PIKACHU hp:", tostring(game.save.party[1].hp),
+ "status:", tostring(game.save.party[1].status),
+ "EVENT_GOT_STARTER:", tostring(game.save.flags.EVENT_GOT_STARTER))
+ idle()
+ end
+
+ local mon = monster()
+ check("the monster object loaded on " .. MAP, mon ~= nil)
+ if mon then
+ U.log("monster at", mon.cellX, mon.cellY, "(objects file says 6, 5)")
+ end
+
+ -- the whole point of the arrival: the confused beat must not have run, so
+ -- Pikachu is still on the follow loop rather than parked beside Bill
+ check("the entry beat was skipped for a statused starter",
+ ow.pikachuBillsScene ~= true)
+ check("no question bubble is up from the entry beat", ow.emote == nil)
+
+ local function facingMonster()
+ if not mon then return false end
+ local fx, fy = ow.player:facingCell()
+ return ow:npcAtCell(fx, fy) == mon
+ end
+
+ if mon and not facingMonster() then
+ -- a map edit or a mod moved the object: take any free walkable neighbour,
+ -- preferring the cell above it since that is the facing-down branch.
+ -- {dx, dy, facing} is the offset from the monster to the stand cell plus
+ -- the direction that looks back at it, so +1 on y means facing up.
+ local sides = {
+ { 0, -1, "down" }, { 0, 1, "up" }, { -1, 0, "right" }, { 1, 0, "left" },
+ }
+ for _, s in ipairs(sides) do
+ local cx, cy = mon.cellX + s[1], mon.cellY + s[2]
+ if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy)
+ and not ow:npcAtCell(cx, cy) then
+ U.log(("cell (%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
+ cx, cy, "facing", s[3])
+ U.teleport(game, MAP, cx, cy, s[3])
+ U.wait(20)
+ ow = game.overworld
+ npc, mon = follower(), monster()
+ break
+ end
+ end
+ end
+ check("the player is standing against the monster", facingMonster())
+ check("the player faces down, the branch Bill walks around",
+ ow.player.facing == "down")
+ U.log("player at", ow.player.cellX, ow.player.cellY,
+ "| Pikachu at", npc and npc.cellX, npc and npc.cellY)
+
+ -- TryApplyPikachuMovementData keys off where Pikachu stands relative to the
+ -- player, not off its sprite facing: above the player is WatchPlayer1 (left,
+ -- down), level and east is WatchPlayer2 (up, left twice, down). Anything
+ -- else drops the data and no watch route runs at all.
+ if npc then
+ local route = npc.cellY < ow.player.cellY and "WatchPlayer1"
+ or (npc.cellY == ow.player.cellY and npc.cellX > ow.player.cellX)
+ and "WatchPlayer2" or nil
+ check("Pikachu stands where one of the two watch tables applies",
+ route ~= nil)
+ U.log("geometry picks", route or "neither table")
+ end
+
+ -- a muted run makes the text beeps and Bill's step sfx indistinguishable
+ -- from silence (Sound.setVolumeLevel reads save.options.sfxVol)
+ local sfxVol = game.save.options and game.save.options.sfxVol
+ U.log("save.options.sfxVol:", tostring(sfxVol))
+ if (sfxVol or 0) == 0 then
+ U.log("sfx volume is 0, so no beep can be heard whether or not one plays;")
+ U.log("raise it in OPTIONS before judging the sound half")
+ end
+
+ -- record every cell the follower occupies so a route that runs and is then
+ -- yanked back by the follow loop can be told from one that holds
+ local trail = {}
+ local function sample()
+ if not npc then return end
+ local last = trail[#trail]
+ if not last or last.x ~= npc.cellX or last.y ~= npc.cellY
+ or last.f ~= npc.facing then
+ trail[#trail + 1] = { x = npc.cellX, y = npc.cellY, f = npc.facing }
+ end
+ end
+ local function step(n)
+ for _ = 1, n do
+ sample()
+ U.wait(1)
+ end
+ sample()
+ end
+ sample()
+
+ -- talk, take YES (ChoiceBox defaults to it), then close the separation
+ -- system line: that close is what runs the walk-around branch. Stop
+ -- pressing the moment the boxes are gone so a stray A cannot re-interact.
+ -- FAST text: the reveal is per-character (TextBox drawChars reads
+ -- save.options.textSpeed) and Bill's opener alone is four pages, so at
+ -- the MEDIUM default the mash budget below runs out mid-box
+ game.save.options = game.save.options or {}
+ game.save.options.textSpeed = 1
+ U.tap(game, "a")
+ step(20)
+ for _ = 1, 200 do
+ if game.stack:top() == ow then break end
+ U.tap(game, "a")
+ step(8)
+ end
+ check("the dialogue finished and the map is running the walk",
+ game.stack:top() == ow)
+
+ -- let both scripted walks play out; Bill's is four legs, Pikachu's two
+ for _ = 1, 400 do
+ if #ow.scriptMoves == 0 and not ow.runner:isRunning() then break end
+ step(1)
+ end
+ step(40)
+
+ local cells = {}
+ for _, c in ipairs(trail) do
+ cells[#cells + 1] = ("(%d,%d %s)"):format(c.x, c.y, tostring(c.f))
+ end
+ U.log("Pikachu's cells through the scene:", table.concat(cells, " "))
+
+ if npc then
+ check("Pikachu ended west of the player, clear of Bill's detour",
+ npc.cellY == ow.player.cellY and npc.cellX < ow.player.cellX)
+ -- read the facing out of the recorded trail rather than off the sprite:
+ -- by now an idle glance (pokeyellow Func_fc803) may already have turned
+ -- its head, and that is vanilla, not the route failing to end on LOOK_RIGHT
+ local lookedRight = false
+ for _, c in ipairs(trail) do
+ if c.y == ow.player.cellY and c.x < ow.player.cellX
+ and c.f == "right" then
+ lookedRight = true
+ end
+ end
+ check("PIKAMOVEMENT_LOOK_RIGHT left it facing the player", lookedRight)
+ check("it moved at all (the watch route ran)", #trail > 1)
+ end
+ check("Bill reached the machine (the separator is armed)",
+ game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR == true)
+
+ -- hold still for a moment and see whether the follow loop reclaims it;
+ -- cells only, because the idle glances (PikachuFollower's port of
+ -- pokeyellow Func_fc803, a random look every ~0x20 frames) legitimately
+ -- turn its head while it waits
+ local settled = npc and { x = npc.cellX, y = npc.cellY }
+ step(90)
+ if npc and settled then
+ check("it stayed put once the scene ended",
+ npc.cellX == settled.x and npc.cellY == settled.y)
+ end
+
+ if U.shot(game, SHOT_DIR .. "/bug455_bills_watch.png") then
+ U.log("captured", SHOT_DIR .. "/bug455_bills_watch.png")
+ end
+
+ U.log("The scene has already played; the screen is where it left off.")
+ U.log("Pikachu should have stepped one cell west and one south out of Bill's")
+ U.log("way, then turned to face right and held there watching you while Bill")
+ U.log("climbed into the machine. Two near misses to watch for: Pikachu")
+ U.log("parked beside Bill with a question bubble the moment you walked in,")
+ U.log("which means the status gate never took and the watch beat cannot")
+ U.log("happen; or Pikachu walking the route and snapping straight back to")
+ U.log("your heel, which reads as a twitch instead of a reposition.")
+ U.log("Input is yours now. Stepping out to Route 25 puts the monster back")
+ U.log("(Route25ToggleBillsScript), so walk back in to (6,4) facing down and")
+ U.log("talk to it again to replay the whole beat as often as you like.")
+
+ idle()
+end
diff --git a/tests/drivers/host_console_bug606_test.lua b/tests/drivers/host_console_bug606_test.lua
new file mode 100644
index 00000000..4128cedd
--- /dev/null
+++ b/tests/drivers/host_console_bug606_test.lua
@@ -0,0 +1,224 @@
+-- Manual check that launcher actions no longer flash console windows (#606).
+-- src/core/HostShell.lua claims one hidden console at boot so every curl /
+-- PowerShell child inherits it instead of allocating (and showing) its own.
+-- No pokered counterpart: this is desktop launcher plumbing, so the standing
+-- spot below is only to give the window something to be (pokered
+-- data/maps/objects/PalletTown.asm: (10, 8) is clear of the three
+-- object_events and the three warps).
+-- POKEPORT_DRIVER=tests/drivers/host_console_bug606_test.lua POKEPORT_IDENTITY=bug606 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
+-- Leave POKEPORT_SPEED unset: fast-forward desyncs audio and logic ordering,
+-- and it would also step this driver several times per rendered frame while a
+-- blocking fetch is in flight.
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local HostShell = require("src.core.HostShell")
+ local RomImporter = require("src.import.RomImporter")
+
+ local isWin = love.system.getOS() == "Windows"
+ local passed, failed = 0, 0
+ local report = {}
+
+ -- On Windows the packaged game is love.exe, which has no console, so print()
+ -- goes nowhere a reporter can read -- exactly the platform this bug lives on.
+ -- Mirror every line into the save dir and put the count on screen so the
+ -- PASS/FAIL block is still legible there.
+ local REPORT = "bug606_report.txt"
+ pcall(love.filesystem.write, REPORT, "")
+ local function say(...)
+ U.log(...)
+ local parts = {}
+ for i = 1, select("#", ...) do parts[#parts + 1] = tostring((select(i, ...))) end
+ local line = table.concat(parts, " ")
+ report[#report + 1] = line
+ pcall(love.filesystem.append, REPORT, line .. "\n")
+ end
+
+ local function check(label, ok)
+ if ok then passed = passed + 1 else failed = failed + 1 end
+ say(ok and "PASS" or "FAIL", label)
+ return ok
+ end
+
+ local function fileHas(path, needle)
+ local ok, body = pcall(love.filesystem.read, path)
+ return ok and type(body) == "string" and body:find(needle, 1, true) ~= nil
+ end
+
+ check("HostShell exposes hideHostConsole", type(HostShell.hideHostConsole) == "function")
+
+ -- Wiring: an unwired helper fixes nothing, and it has to run before the
+ -- self-updater boot shell, which is the first thing that can shell out.
+ local okMain, mainSrc = pcall(love.filesystem.read, "main.lua")
+ local atCall = okMain and type(mainSrc) == "string"
+ and mainSrc:find("hideHostConsole", 1, true) or nil
+ local atBoot = okMain and type(mainSrc) == "string"
+ and mainSrc:find("Boot.run", 1, true) or nil
+ check("love.load claims the console before anything shells out",
+ atCall ~= nil and atBoot ~= nil and atCall < atBoot)
+
+ local hid = HostShell.hideHostConsole()
+ check("calling it twice answers the same (memoized, so no second console)",
+ HostShell.hideHostConsole() == hid)
+ if not isWin then
+ check("no console is allocated off Windows", hid == false)
+ end
+ say("host:", love.system.getOS(), "-- console claimed:", tostring(hid),
+ "-- POKEPORT_CONSOLE:", tostring(os.getenv("POKEPORT_CONSOLE")))
+
+ -- The ROM picker answers by writing the chosen path to stdout and letting
+ -- commandOutput read it back through HostShell.popen; if that write ever
+ -- breaks the launcher just silently refuses every ROM, which is the second
+ -- way this fix can go wrong.
+ check("the Windows ROM picker still writes its pick to stdout",
+ fileHas("src/import/RomImporter.lua", "[Console]::Write($d.FileName)"))
+
+ local okFfi, ffi = pcall(require, "ffi")
+ if isWin and okFfi then
+ -- HostShell already declared GetConsoleWindow if it got that far; a repeat
+ -- cdef raises, so each one gets its own pcall.
+ pcall(ffi.cdef, "void *GetConsoleWindow(void);")
+ pcall(ffi.cdef, "int IsWindowVisible(void *hWnd);")
+ local okCall, hwnd = pcall(function() return ffi.C.GetConsoleWindow() end)
+ if hid then
+ check("the process owns a console for its children to inherit",
+ okCall and hwnd ~= nil)
+ -- The near-miss: AllocConsole worked but ShowWindow did not resolve, so
+ -- the console exists and sits on screen for the whole session.
+ local okVis, visible = pcall(function() return ffi.C.IsWindowVisible(hwnd) end)
+ check("and that console window is hidden", okVis and visible == 0)
+ else
+ say("This run did not claim a console: either POKEPORT_CONSOLE=1 is set,")
+ say("or it was started from a terminal (lovec.exe, what scripts\\run.ps1")
+ say("prefers), which already had one. Re-run under love.exe to judge #606.")
+ end
+ end
+
+ -- Fire the three host tools the launcher actually spawns, through the same
+ -- funnel they use (src/core/HostShell.lua:popen). Each of these was one
+ -- console flash before the fix.
+ local probes = isWin and {
+ { "curl, the update and mod-index fetch", "curl --version", "curl" },
+ { "powershell, the ROM and mod pickers",
+ 'powershell -NoProfile -Command "[Console]::Write(\'pokeport-ok\')"', "pokeport-ok" },
+ { "cmd, the update downloader's start /b", "cmd /c echo pokeport-ok", "pokeport-ok" },
+ } or {
+ { "curl, the update and mod-index fetch", "curl --version", "curl" },
+ { "the shell the pickers run under", "echo pokeport-ok", "pokeport-ok" },
+ }
+
+ local function runProbes(quiet)
+ for _, p in ipairs(probes) do
+ local out
+ local pipe = HostShell.popen(p[2])
+ if pipe then
+ out = pipe:read("*a")
+ pipe:close()
+ end
+ local ok = type(out) == "string" and out:find(p[3], 1, true) ~= nil
+ if quiet then
+ say(ok and "PASS" or "FAIL", p[1] .. " still answers")
+ else
+ check(p[1] .. " still answers", ok)
+ end
+ U.wait(20) -- space them out so a flash is countable by eye
+ end
+ end
+ runProbes(false)
+
+ -- The launcher's boot release check is skipped under POKEPORT_DRIVER
+ -- (RomImporter's updaterAllowed), so start it by hand: it is a love.thread
+ -- whose curl calls popped their own consoles too, and the hidden one is
+ -- process-wide, so the thread is covered without touching check_worker.lua.
+ local okCheck, Check = pcall(require, "src.update.Check")
+ if okCheck and Check then
+ pcall(Check.start)
+ for _ = 1, 240 do
+ local st = Check.state()
+ if st.status ~= "checking" then break end
+ U.wait(1)
+ end
+ local st = Check.state()
+ check("the update check ran and reached a verdict",
+ st.status ~= "checking" and st.status ~= "idle")
+ say("update check says:", st.status, st.error and ("(" .. tostring(st.error) .. ")") or "")
+ end
+
+ -- Prove the window is rendering before the launcher takes the frame over.
+ local MAP, STAND = "PALLET_TOWN", { x = 10, y = 8, facing = "down" }
+ U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
+ U.wait(10)
+ local ow = game.overworld
+ if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
+ -- a map edit moved the ground under us; any free neighbour will do
+ for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
+ local cx, cy = STAND.x + d[1], STAND.y + d[2]
+ if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
+ U.teleport(game, MAP, cx, cy, "down")
+ U.wait(10)
+ break
+ end
+ end
+ end
+ local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
+ check("the window rendered", U.shot(game, SHOT_DIR .. "/bug606_console.png"))
+
+ -- A driver run boots straight past the launcher (main.lua: POKEPORT_DRIVER is
+ -- a scripted run), so bring a real interactive one up and take over the
+ -- handlers it normally owns. This is the panel the reporter needs: Mods >
+ -- Find mods adds a repo and installs, and a column that is not imported yet
+ -- shows Choose ROM.
+ local imp = RomImporter.new(function() end, { launcher = true })
+ imp.play = function() U.log("Play is inert here: the game is already booted.") end
+ if okCheck and Check then imp.Check = Check end -- so the update banner draws
+ if os.getenv("POKEPORT_PICKER") == "1" then
+ -- every column offers Choose ROM again, so the picker can be opened even on
+ -- a machine where all three games are already imported. Cancelling the
+ -- dialog is enough; only picking a file would re-extract.
+ imp.forceImport = true
+ imp.ready = {}
+ end
+
+ love.draw = function()
+ imp:update(love.timer.getDelta())
+ imp:draw()
+ local w, h = love.graphics.getWidth(), love.graphics.getHeight()
+ love.graphics.setColor(0, 0, 0, 0.72)
+ love.graphics.rectangle("fill", 0, h - 54, w, 54)
+ love.graphics.setColor(1, 1, 1, 1)
+ love.graphics.print(("#606 checks: %d passed, %d failed"):format(passed, failed), 10, h - 48)
+ love.graphics.print("F9 runs curl / powershell / the update check again", 10, h - 34)
+ love.graphics.print("full log: " .. love.filesystem.getSaveDirectory() .. "/" .. REPORT,
+ 10, h - 20)
+ end
+ love.mousepressed = function(x, y, button) imp:mousepressed(x, y, button or 1) end
+ love.textinput = function(t) imp:textinput(t) end
+ -- backspace and enter in the "add an index" field arrive here, not through
+ -- textinput; f9 is kept for the loop below so the launcher never sees it
+ love.keypressed = function(key)
+ if key ~= "f9" then imp:keypressed(key) end
+ end
+
+ say("The launcher on screen is the real one. Open Mods > Find mods, add an")
+ say("index, install something, and click Choose ROM on a column that is not")
+ say("imported yet (POKEPORT_PICKER=1 brings that button back on every column).")
+ say("Right: not one black console window appears or blinks, at any point,")
+ say("while the file dialog still opens normally and the fetches still land.")
+ say("Near-miss one: a single console sits there permanently instead of many")
+ say("flashes, which means it was allocated but ShowWindow never resolved.")
+ say("Near-miss two: the picker dialog opens, you choose a ROM, and the")
+ say("launcher just carries on as if you had cancelled -- that is the")
+ say("PowerShell stdout write, not the console.")
+
+ local held = false
+ while true do
+ -- keep F9 for the reporter and hand every other key to the launcher
+ local down = love.keyboard and love.keyboard.isDown("f9")
+ if down and not held then
+ say("re-running the host tools; watch the desktop, not the window")
+ runProbes(true)
+ if okCheck and Check then pcall(Check.start) end
+ end
+ held = down
+ coroutine.yield()
+ end
+end
diff --git a/tests/drivers/item_picker_wheel_bug595_test.lua b/tests/drivers/item_picker_wheel_bug595_test.lua
new file mode 100644
index 00000000..fba0b86c
--- /dev/null
+++ b/tests/drivers/item_picker_wheel_bug595_test.lua
@@ -0,0 +1,228 @@
+-- Manual check that the save editor's item lists scroll under the wheel (#595).
+-- The ADD ITEM picker lists every non-badge id in pokered constants/item_constants.asm,
+-- which is far more than one screenful, and typing a query used to be the only way
+-- past the first page. This is the editor app, not the game, so there is no
+-- POKEPORT_DRIVER hook: this file runs itself, checks the seams, seeds a save with
+-- a stocked bag and PC, then opens the real editor on it. Do not set POKEPORT_SPEED.
+-- luajit tests/drivers/item_picker_wheel_bug595_test.lua
+
+package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
+ .. ";./tools/save-editor/panels/?.lua"
+
+love = require("tests.love_stub")
+
+local Kit = require("Kit")
+local App = require("App")
+local Ops = require("Ops")
+local SaveData = require("src.core.SaveData")
+
+-- unbuffered, or a redirected run would show the editor's own output before
+-- the checks that were printed ahead of it
+io.stdout:setvbuf("line")
+
+local function log(...) print("[driver]", ...) end
+
+local fails = 0
+local function check(label, ok)
+ if not ok then fails = fails + 1 end
+ log(ok and "PASS" or "FAIL", label)
+ return ok
+end
+
+-- ------------------------------------------------------------------ Kit seam
+-- Kit.scroll is the whole fix; every panel rule below is a consequence of it.
+check("Kit.scroll exists", type(Kit.scroll) == "function")
+
+Kit.beginFrame(50, 50, false, -1)
+check("a notch inside a list body advances three rows",
+ Kit.scroll(0, 0, 100, 100, 0, 250, 10) == 3)
+check("and is consumed, so a second list cannot eat the same notch",
+ Kit.wheelY == 0)
+Kit.endFrame()
+
+Kit.beginFrame(50, 50, false, -1)
+check("the last page is the floor",
+ Kit.scroll(0, 0, 100, 100, 245, 250, 10) == 240)
+Kit.endFrame()
+
+Kit.beginFrame(500, 500, false, -1)
+check("a list the pointer is not over ignores the wheel",
+ Kit.scroll(0, 0, 100, 100, 0, 250, 10) == 0)
+Kit.endFrame()
+
+Kit.beginFrame(50, 50, false, -1)
+Kit.blockClicks = true
+check("the species-picker modal shield stops the wheel like it stops a click",
+ Kit.scroll(0, 0, 100, 100, 0, 250, 10) == 0)
+Kit.blockClicks = false
+Kit.endFrame()
+
+-- ------------------------------------------------------------- seeded save
+-- A save with one page of items proves nothing: stock the bag to its cap and
+-- push a couple of pages into PC storage so both pagers have somewhere to go.
+local SEED_DIR = (os.getenv("TMPDIR") or "/tmp/"):gsub("/*$", "/") .. "pokeport-bug595"
+local SEED = SEED_DIR .. "/save.lua"
+os.execute('mkdir -p "' .. SEED_DIR .. '" 2>/dev/null')
+
+local seedFile = io.open(SEED, "wb")
+if seedFile then
+ seedFile:write(SaveData.encode(SaveData.newGame()))
+ seedFile:close()
+end
+check("a scratch save was written to " .. SEED, seedFile ~= nil)
+
+local ok, err = pcall(App.load, SEED, { version = "red" })
+check("the editor loads it headless (data/generated present): " .. tostring(err), ok)
+
+local S = ok and App.getState() or nil
+if S then
+ S.tab = "items"
+ local stocked, pcKinds = 0, 0
+ S.save.pcItems = S.save.pcItems or {}
+ for _, id in ipairs(S.cat.items) do
+ if not Ops.isBadgeId(id) then
+ stocked = stocked + 1
+ if stocked <= 20 then
+ S.save.inventory[id] = 3
+ elseif pcKinds < 30 then
+ pcKinds = pcKinds + 1
+ S.save.pcItems[id] = 5
+ end
+ end
+ end
+ local out = io.open(SEED, "wb")
+ if out then out:write(SaveData.encode(S.save)) out:close() end
+ App.load(SEED, { version = "red" })
+ S = App.getState()
+ S.tab = "items"
+
+ -- The panel is layout over Ops, so its rects are not written down anywhere:
+ -- read them back off the Kit.scroll calls the draw makes, which is also the
+ -- only honest way to point the fake mouse at the right list. If a rect goes
+ -- missing the hand-off below still happens, with the count in the log.
+ local rects = {}
+ local realScroll = Kit.scroll
+ Kit.scroll = function(x, y, w, h, offset, total, perPage)
+ rects[#rects + 1] = { x = x, y = y, w = w, h = h, total = total }
+ return realScroll(x, y, w, h, offset, total, perPage)
+ end
+ local fields = {}
+ local realField = Kit.textfield
+ Kit.textfield = function(id, x, y, w, h, value, placeholder)
+ fields[id] = { x = x, y = y, w = w, h = h }
+ return realField(id, x, y, w, h, value, placeholder)
+ end
+ local counters = {}
+ local realRight = Kit.textRight
+ Kit.textRight = function(name, str, ...)
+ counters[#counters + 1] = tostring(str)
+ return realRight(name, str, ...)
+ end
+
+ App.draw()
+ check("the Items tab hands three lists to Kit.scroll (picker, bag, PC)",
+ #rects == 3)
+ local pick, bag, pc = rects[1], rects[2], rects[3]
+
+ local function pointAt(r)
+ love.mouse.getPosition = function() return r.x + r.w / 2, r.y + r.h / 2 end
+ end
+ local function counterLine()
+ for _, str in ipairs(counters) do
+ if str:match("^%d+%-%d+ of %d+$") then return str end
+ end
+ return nil
+ end
+
+ if pick then
+ check("the picker caption carries a position counter, not a +N more",
+ counterLine() ~= nil)
+ log("the picker counter reads", tostring(counterLine()),
+ "over a catalog of " .. tostring(pick.total) .. " ids")
+
+ pointAt(pick)
+ counters = {}
+ App.wheelmoved(0, -1)
+ App.draw()
+ check("one notch over the picker advances it three rows",
+ S.itemPickOffset == 3)
+ check("and neither the bag nor the PC column moved with it",
+ S.bagOffset == 0 and S.pcOffset == 0)
+ log("the counter now reads", tostring(counterLine()))
+ end
+
+ if bag then
+ pointAt(bag)
+ local before = S.itemPickOffset
+ App.wheelmoved(0, -1)
+ App.draw()
+ check("a notch over the BAG column moves the bag pager's own offset",
+ S.bagOffset > 0)
+ check("and leaves the picker where it was", S.itemPickOffset == before)
+ end
+
+ if pc then
+ pointAt(pc)
+ local before = S.bagOffset
+ App.wheelmoved(0, -1)
+ App.draw()
+ check("a notch over PC STORAGE moves the PC list", S.pcOffset > 0)
+ check("and leaves the bag where it was", S.bagOffset == before)
+ end
+
+ -- Typing has to rewind the view: a query that matches ids near the top of a
+ -- catalog the view had already scrolled past would otherwise show an empty
+ -- body over a non-zero offset, which reads exactly like "no item matches".
+ local field = fields["item-query"]
+ if pick and field then
+ pointAt(pick)
+ for _ = 1, 12 do
+ App.wheelmoved(0, -1)
+ App.draw()
+ end
+ local deep = S.itemPickOffset
+ love.mouse.getPosition = function() return field.x + 4, field.y + 4 end
+ App.mousepressed(field.x + 4, field.y + 4, 1)
+ App.draw()
+ App.textinput("P")
+ App.draw()
+ check("a keystroke in the search box rewinds the deep-scrolled list to the top",
+ deep > 0 and S.itemQuery == "P" and S.itemPickOffset == 0)
+ end
+
+ -- The map tab spends the wheel on zoom and must keep it.
+ S.tab = "items"
+ local zoom = S.mapZoom
+ App.wheelmoved(0, -1)
+ App.draw()
+ check("a notch outside the Map tab leaves the map camera alone", S.mapZoom == zoom)
+ S.tab = "map"
+ App.wheelmoved(0, 1)
+ check("the Map tab still zooms on the wheel", S.mapZoom > zoom)
+
+ Kit.scroll, Kit.textfield, Kit.textRight = realScroll, realField, realRight
+ App.unload()
+end
+
+log(fails == 0 and "all seam checks passed" or (fails .. " seam checks failed"))
+
+-- --------------------------------------------------------------- hand-off
+log("Opening the editor on that seeded save. Click the Items tab, put the pointer")
+log("over the ADD ITEM list and roll the wheel: rows step three at a time and the")
+log("counter on the caption line tracks them, stopping on the last page; clicking a")
+log("row still picks it for the two add buttons. Over BAG or PC STORAGE the wheel")
+log("moves the same rows the Prev/Next pager does and the pager label keeps up.")
+log("The near miss to watch for is both columns jumping on one notch, or the panel")
+log("scrolling underneath the species picker while that modal is open on Party.")
+
+local love_bin = os.getenv("LOVE") or "love"
+os.execute(love_bin .. ' . --editor --save "' .. SEED .. '"')
+
+log("Editor closed. Reopen it on the same stocked save any time with:")
+log(" " .. love_bin .. ' . --editor --save "' .. SEED .. '"')
+
+-- Park rather than exit: the driver is never the thing that ends the session,
+-- so the terminal stays put until the reader Ctrl-Cs it.
+while true do
+ os.execute("sleep 3600")
+end
diff --git a/tests/drivers/launcher_mods_bulk_bug647_test.lua b/tests/drivers/launcher_mods_bulk_bug647_test.lua
new file mode 100644
index 00000000..244063df
--- /dev/null
+++ b/tests/drivers/launcher_mods_bulk_bug647_test.lua
@@ -0,0 +1,226 @@
+-- Manual check for the launcher's Enable all / Disable all chips (#647).
+-- POKEPORT_DRIVER skips the launcher (main.lua boots straight into the game),
+-- so this stands a second launcher up on the MODS tab and hands the mouse over.
+-- No pokered cite applies: this is launcher chrome, nothing in the ROM-side
+-- game is involved, and there is no map position to derive.
+-- POKEPORT_DRIVER=tests/drivers/launcher_mods_bulk_bug647_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
+-- Do not set POKEPORT_SPEED: it multiplies the act+step loop per rendered
+-- frame, so the driver would run several steps between two launcher draws and
+-- read rects from a frame that was never on screen (and it desyncs audio too).
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local RomImporter = require("src.import.RomImporter")
+ local LauncherMods = require("src.mods.LauncherMods")
+ local SaveData = require("src.core.SaveData")
+
+ local function check(label, ok)
+ U.log(ok and "PASS" or "FAIL", label)
+ return ok
+ end
+
+ -- Left-click the middle of a launcher rect. The button argument is load
+ -- bearing: the confirm modal's dispatch returns early on anything but 1.
+ local function click(importer, r)
+ importer:mousepressed(r.x + r.width / 2, r.y + r.height / 2, 1)
+ end
+
+ -- ---- preconditions, before anything is drawn or written
+
+ check("LauncherMods.setAllEnabled is the one-write bulk path",
+ type(LauncherMods.setAllEnabled) == "function")
+ check("RomImporter:_setAllMods exists for the chips to call",
+ type(RomImporter._setAllMods) == "function")
+ check("a window is up to draw the launcher into",
+ love.window and love.window.isOpen and love.window.isOpen()
+ and love.graphics.getWidth() > 0)
+
+ -- The panel reads the same options.mods the loader writes, so a read-only or
+ -- quarantined options file would make every click look like it did nothing.
+ local options = SaveData.loadOptions()
+ local savedMods = {}
+ for id, v in pairs(options.mods or {}) do savedMods[id] = v end
+ check("options.lua round-trips (the chips persist through it)",
+ SaveData.saveOptions(options) ~= nil)
+
+ local mods = LauncherMods.list()
+ check("at least two mods are installed to act on (found "
+ .. #mods .. ")", #mods >= 2)
+ if #mods < 2 then
+ U.log("mods/ in the save dir (or the repo's mods/ on a source run) is what")
+ U.log("gets scanned one level deep; drop two mod folders there and rerun.")
+ end
+ local names = {}
+ for _, m in ipairs(mods) do names[#names + 1] = m.id end
+ U.log("installed mods:", table.concat(names, ", "))
+
+ -- The experimental confirm is half of what #647 has to get right, and none of
+ -- the bundled example mods are flagged, so plant one rather than let that
+ -- branch go unseen. Manifest.validate is pure (no filesystem), but the real
+ -- loader wants the entry chunk to exist if the human later presses Play.
+ local SEED = "driver_experimental_647"
+ local seeded = false
+ local haveExperimental = false
+ for _, m in ipairs(mods) do
+ if m.experimental then haveExperimental = true end
+ end
+ if not haveExperimental then
+ love.filesystem.createDirectory("mods")
+ love.filesystem.createDirectory("mods/" .. SEED)
+ local manifest = ([[{
+ "id": "%s",
+ "name": "Driver Experimental Mod",
+ "version": "1.0.0",
+ "entry": "main.lua",
+ "experimental": true,
+ "description": "Planted by the #647 driver so the Enable all confirm has something to warn about."
+}
+]]):format(SEED)
+ local okM = love.filesystem.write("mods/" .. SEED .. "/manifest.json", manifest)
+ love.filesystem.write("mods/" .. SEED .. "/main.lua", "return {}\n")
+ seeded = okM and true or false
+ check("planted an experimental mod so the confirm can fire", seeded)
+ U.log("it lives in " .. love.filesystem.getSaveDirectory() .. "/mods/" .. SEED)
+ U.log("remove it with: rm -rf '" .. love.filesystem.getSaveDirectory()
+ .. "/mods/" .. SEED .. "'")
+ mods = LauncherMods.list()
+ end
+
+ -- ---- stand the launcher up and take the callbacks off main.lua
+
+ local co = coroutine.running()
+ local CALLBACKS = {
+ "update", "draw", "mousepressed", "mousereleased", "mousemoved",
+ "keypressed", "keyreleased", "textinput", "wheelmoved", "filedropped",
+ "focus",
+ }
+ local prev = {}
+ for _, name in ipairs(CALLBACKS) do prev[name] = love[name] end
+ local function restoreHost()
+ for name, fn in pairs(prev) do love[name] = fn end
+ end
+
+ -- Play hands back to the game main.lua already booted (POKEPORT_VERSION), not
+ -- to whichever column was clicked: the driver cannot reach main.lua's local
+ -- bootGame, and only one cache is mounted.
+ local importer = RomImporter.new(function()
+ restoreHost()
+ U.log("launcher closed, the already-booted game takes the window back")
+ end, { launcher = true })
+ importer.tab = "mods"
+
+ love.update = function(dt)
+ importer:update(dt)
+ if coroutine.status(co) == "suspended" then
+ local ok, err = coroutine.resume(co, game)
+ if not ok then print("driver error: " .. tostring(err)) end
+ end
+ end
+ love.draw = function() importer:draw() end
+ love.mousepressed = function(x, y, button) importer:mousepressed(x, y, button) end
+ love.mousereleased = function() end
+ love.mousemoved = function() end
+ love.keypressed = function(key) importer:keypressed(key) end
+ love.keyreleased = function() end
+ love.textinput = function(text) importer:textinput(text) end
+ -- RomImporter.new already chained the previous handler onto love.wheelmoved;
+ -- point it straight at the launcher so a scroll cannot also reach the game.
+ love.wheelmoved = function(dx, dy) importer:wheelmoved(dx, dy) end
+ love.filedropped = function(file) importer:filedropped(file) end
+ love.focus = function(f) importer:focus(f) end
+
+ -- rects are built inside draw(), so every read below needs a rendered frame
+ -- between the change and the check
+ U.wait(3)
+
+ local ena, dis = importer.modEnableAllRect, importer.modDisableAllRect
+ local imp = importer.modImportRect
+ check("Enable all / Disable all have rects on the MODS tab",
+ ena ~= nil and dis ~= nil)
+ if ena and dis and imp then
+ check("both sit left of Import mod .zip",
+ ena.x + ena.width <= imp.x and dis.x + dis.width <= imp.x)
+ check("Enable all is left of Disable all and they do not overlap",
+ ena.x + ena.width <= dis.x)
+ check("they are on the header row, centred against the import button",
+ math.abs((ena.y + ena.height / 2) - (imp.y + imp.height / 2)) <= 2)
+ end
+
+ -- Disable all: one write, count to zero, and a notice that names the number.
+ if dis then
+ click(importer, dis)
+ U.wait(2)
+ local after = LauncherMods.list()
+ local on = 0
+ for _, m in ipairs(after) do if m.enabled then on = on + 1 end end
+ check("Disable all switched every mod off", on == 0)
+ check("the notice counts what it did (" ..
+ tostring(importer.modNotice and importer.modNotice.text) .. ")",
+ importer.modNotice ~= nil
+ and importer.modNotice.text:match("^Disabled %d+ mods%.$") ~= nil)
+ end
+
+ -- Enable all with an experimental mod in the list must stop at the confirm,
+ -- and Cancel must leave the list exactly as it was.
+ if ena then
+ click(importer, ena)
+ U.wait(2)
+ local c = importer._modConfirm
+ check("Enable all armed the experimental confirm instead of enabling",
+ c ~= nil and c.kind == "enableAll")
+ local stillOff = 0
+ for _, m in ipairs(LauncherMods.list()) do
+ if not m.enabled then stillOff = stillOff + 1 end
+ end
+ check("nothing was enabled while the confirm is up",
+ stillOff == #LauncherMods.list())
+ if c and importer._modConfirmNo then
+ click(importer, importer._modConfirmNo)
+ U.wait(2)
+ local on = 0
+ for _, m in ipairs(LauncherMods.list()) do if m.enabled then on = on + 1 end end
+ check("Cancel closed the confirm and left the list untouched",
+ importer._modConfirm == nil and on == 0)
+ end
+ end
+
+ -- #433 shape: a rect that outlives its panel stays clickable over the next
+ -- tab. _resetFrameRects has to nil both chips every frame.
+ importer.tab = "red"
+ U.wait(3)
+ check("switching to the RED tab drops both chip rects",
+ importer.modEnableAllRect == nil and importer.modDisableAllRect == nil)
+ importer.tab = "mods"
+ U.wait(3)
+
+ -- Narrow column: the chips are dropped, never squeezed onto the count.
+ local ww, wh, flags = love.window.getMode()
+ love.window.setMode(480, wh, flags)
+ U.wait(4)
+ check("at minimum window width the chips vanish rather than overlap the count",
+ importer.modEnableAllRect == nil and importer.modDisableAllRect == nil)
+ love.window.setMode(ww, wh, flags)
+ U.wait(4)
+ check("the chips come back when the window is wide again",
+ importer.modEnableAllRect ~= nil and importer.modDisableAllRect ~= nil)
+
+ -- put the enable-state back the way the human found it
+ local restore = SaveData.loadOptions()
+ restore.mods = savedMods
+ SaveData.saveOptions(restore)
+ importer:_refreshMods()
+ importer.modNotice = nil
+ U.wait(2)
+
+ U.log("The MODS tab is up with the enable-state back as you left it. Enable all")
+ U.log("and Disable all are the two chips immediately left of Import mod .zip;")
+ U.log("clicking one should flip every switch at once, move the \"N of M enabled\"")
+ U.log("count, and print \"Disabled N mods.\" underneath, with Enable all stopping")
+ U.log("at an Experimental mods confirm first. Watch for the chips crowding or")
+ U.log("overlapping that count as you drag the window narrower (they should just")
+ U.log("disappear), and for a click on empty space in the RED tab, where the chips")
+ U.log("used to be, doing something anyway. Quit and rerun to check it persisted.")
+
+ while true do
+ coroutine.yield()
+ end
+end
diff --git a/tests/drivers/mod_index_bug597_test.lua b/tests/drivers/mod_index_bug597_test.lua
new file mode 100644
index 00000000..c55c93f8
--- /dev/null
+++ b/tests/drivers/mod_index_bug597_test.lua
@@ -0,0 +1,153 @@
+-- Manual check that pulling a mod index works without curl (#597): every
+-- remote fetch now goes through src/core/HostShell.lua, which prefers curl and
+-- falls back to love.system.httpDownload (the Android GameActivity bridge).
+-- Not a map moment, it lives in the launcher's FIND MODS panel, so this driver
+-- runs the same transport calls that panel makes and then leaves F9 as a
+-- refetch key; the player position is only there to give the window something
+-- to be (pokered data/maps/objects/PalletTown.asm: (10, 8) is clear of the
+-- three object_events and of the three warps).
+-- POKEPORT_DRIVER=tests/drivers/mod_index_bug597_test.lua POKEPORT_IDENTITY=bug597 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
+-- Do not set POKEPORT_SPEED: fast-forward desyncs audio and logic ordering,
+-- and it would also step this driver several times per rendered frame while a
+-- blocking fetch is in flight.
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local HostShell = require("src.core.HostShell")
+ local ModIndex = require("src.mods.ModIndex")
+ local ModUpdate = require("src.mods.ModUpdate")
+
+ local function check(label, ok)
+ U.log(ok and "PASS" or "FAIL", label)
+ return ok
+ end
+
+ -- Source-text checks, because the failure this fixes is a string a human
+ -- reads on a device: the old red "curl is not available on this platform"
+ -- must be gone from both callers, replaced by the transport-neutral wording.
+ local function fileHas(path, needle)
+ local ok, body = pcall(love.filesystem.read, path)
+ return ok and type(body) == "string" and body:find(needle, 1, true) ~= nil
+ end
+
+ check("HostShell exposes the shared transport",
+ type(HostShell.canFetch) == "function"
+ and type(HostShell.httpGet) == "function"
+ and type(HostShell.httpDownload) == "function")
+ check("ModIndex no longer speaks of curl to the player",
+ not fileHas("src/mods/ModIndex.lua", "curl is not available")
+ and fileHas("src/mods/ModIndex.lua", "no network transport"))
+ check("ModUpdate no longer speaks of curl to the player",
+ not fileHas("src/mods/ModUpdate.lua", "curl is not available")
+ and fileHas("src/mods/ModUpdate.lua", "no network transport"))
+ check("ModUpdate.haveCurl still answers (the panel's old gate)",
+ type(ModUpdate.haveCurl) == "function")
+
+ -- The Android half only reaches a device through a rebuilt liblove/APK, so
+ -- confirm the vendored tree really carries the bridge before anyone blames
+ -- the phone for a stale build.
+ check("GameActivity.httpDownload is in the vendored Android tree",
+ fileHas("mobile/android/love/src/main/java/org/love2d/android/GameActivity.java",
+ "public static boolean httpDownload"))
+ check("liblove registers love.system.httpDownload",
+ fileHas("mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp",
+ '{ "httpDownload", w_httpDownload }'))
+
+ local haveCurl = HostShell.haveCurl()
+ check("this machine has a transport (desktop: curl)", HostShell.canFetch())
+ U.log("transport here:", haveCurl and "curl" or "the Android bridge / none")
+
+ -- Sources come from options.modIndexes, i.e. whatever the human added in the
+ -- launcher. POKEPORT_INDEX_URL is the way to point a fresh identity at a
+ -- feed without adding it first.
+ local sources = ModIndex.sources()
+ local envUrl = os.getenv("POKEPORT_INDEX_URL")
+ local source = sources[1]
+ if envUrl and envUrl ~= "" then
+ source = ModIndex.resolveSource(envUrl) or source
+ end
+ check("a mod index is configured", source ~= nil)
+ if source then U.log("index:", source.label or source.feed) end
+
+ local index, fetchErr, meta, seconds
+ if source then
+ -- force = true is exactly what the panel's Refresh button does, and both
+ -- transports block the calling thread, so this frame will be a long one.
+ local t0 = love.timer.getTime()
+ index, fetchErr, meta = ModIndex.fetch(source, { force = true })
+ seconds = love.timer.getTime() - t0
+ check("the feed fetched and parsed", index ~= nil and fetchErr == nil)
+ if index then
+ check("the listing has mods in it", #(index.mods or {}) > 0)
+ U.log(("%d mods in %.1fs%s"):format(#(index.mods or {}), seconds,
+ (meta and meta.fromCache) and " (from cache, so the live fetch failed)" or ""))
+ else
+ U.log("fetch error:", tostring(fetchErr))
+ end
+ end
+
+ -- Thumbnails are the second half of the bug: the cards can list while every
+ -- picture stays blank if the download writes but the rename or size check
+ -- loses the file.
+ if index then
+ local shot
+ for _, entry in ipairs(index.mods or {}) do
+ local url = ModIndex.joinUrl(entry._base, entry.thumbnail)
+ if url then
+ local path, err = ModIndex.downloadThumbnail(url, entry.id)
+ local info = path and love.filesystem.getInfo(path)
+ check("a thumbnail downloaded with bytes in it",
+ path ~= nil and info ~= nil and (info.size or 0) > 0)
+ if not path then U.log("thumbnail error:", tostring(err)) end
+ shot = path
+ break
+ end
+ end
+ if not shot then U.log("no entry in this index carries a thumbnail") end
+ end
+
+ -- Give the window something to be, and prove it is drawing at all.
+ local MAP, STAND = "PALLET_TOWN", { x = 10, y = 8, facing = "down" }
+ U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
+ U.wait(10)
+ local ow = game.overworld
+ if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
+ -- a map edit moved the grass under us; any free neighbour will do
+ for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
+ local cx, cy = STAND.x + d[1], STAND.y + d[2]
+ if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
+ U.teleport(game, MAP, cx, cy, "down")
+ U.wait(10)
+ break
+ end
+ end
+ end
+ local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
+ check("the window rendered", U.shot(game, SHOT_DIR .. "/bug597_modindex.png"))
+
+ U.log("The fetch above already ran; F9 runs it again, timed, so you can")
+ U.log("watch a Refresh cost a second or two rather than stall. The real")
+ U.log("check is the launcher: quit this window, run scripts/run.sh, and in")
+ U.log("Mods > Find mods add an index URL -- cards should fill in within a")
+ U.log("couple of seconds with their thumbnails drawn, same as before.")
+ U.log("Near-misses: cards list but every thumbnail stays blank (the .part")
+ U.log("file never got renamed), or on a phone the message reads \"no network")
+ U.log("transport on this platform\", which means the APK still has the old")
+ U.log("liblove and needs a full scripts/build_android.sh, not a repackage.")
+
+ local held = false
+ while true do
+ local down = love.keyboard and love.keyboard.isDown("f9")
+ if down and not held and source then
+ local t0 = love.timer.getTime()
+ local again, err = ModIndex.fetch(source, { force = true })
+ local dt = love.timer.getTime() - t0
+ if again then
+ U.log(("refetch: %d mods in %.1fs"):format(#(again.mods or {}), dt))
+ else
+ U.log(("refetch failed after %.1fs: %s"):format(dt, tostring(err)))
+ end
+ end
+ held = down
+ coroutine.yield()
+ end
+end
diff --git a/tests/drivers/mod_profiles_bug593_test.lua b/tests/drivers/mod_profiles_bug593_test.lua
new file mode 100644
index 00000000..4288b1fc
--- /dev/null
+++ b/tests/drivers/mod_profiles_bug593_test.lua
@@ -0,0 +1,197 @@
+-- Manual check of the mod manager's PROFILES tab: seeded PROFILE 1, applying a
+-- profile back over a staged toggle, and EXPORT.. writing a .g1rmodlist (#593).
+-- The manager has no pokered analogue; the stand cell comes from pokered
+-- data/maps/objects/PalletTown.asm (warp_event 5, 5 is Red's front door, so the
+-- cell below it is the doorstep), with a walkable-neighbour fallback.
+-- POKEPORT_DRIVER=tests/drivers/mod_profiles_bug593_test.lua POKEPORT_IDENTITY=bug593 POKEPORT_DEV=1 love .
+-- Do not set POKEPORT_SPEED: fast-forward scales the logic clock only, so taps,
+-- the notice timer and the confirm blip stop lining up.
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Screens = require("src.ui.Screens")
+ local ModProfile = require("src.mods.ModProfile")
+
+ local MAP = "PALLET_TOWN"
+ local STAND = { x = 5, y = 6, facing = "up" }
+
+ local function check(label, ok)
+ U.log(ok and "PASS" or "FAIL", label)
+ return ok
+ end
+
+ -- park somewhere ordinary first: the manager is opened over the overworld in
+ -- real play (F10, src/core/Game.lua:446) and draws on top of it
+ U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
+ U.wait(10)
+ local ow = game.overworld
+ if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
+ -- a map edit or a mod moved the doorstep: any free neighbour will do,
+ -- nothing here depends on the exact cell
+ for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
+ local cx, cy = STAND.x + d[1], STAND.y + d[2]
+ if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
+ U.log(("doorstep (%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
+ cx, cy)
+ U.teleport(game, MAP, cx, cy, "up")
+ U.wait(10)
+ break
+ end
+ end
+ end
+
+ local opts = (game.save and game.save.options) or {}
+ local status = (game.mods and game.mods.status and game.mods:status())
+ or game.modStatus or { available = {} }
+ -- two installed mods is the floor for this check: with one mod a profile
+ -- swap cannot be told apart from the mod simply staying on
+ check("at least two mods installed under mods/", #(status.available or {}) >= 2)
+ for _, m in ipairs(status.available or {}) do
+ U.log("installed mod:", m.id, m.enabled and "on" or "off")
+ end
+ check("sfxVol is not zero, so the A press blips", (opts.sfxVol or 0) > 0)
+
+ -- open the manager the way F10 does (Game:keypressed pushes the same id)
+ Screens.push(game, "ManagerState")
+ U.wait(10)
+ local mgr = game.stack:top()
+ check("the mod manager is on top of the stack", mgr and mgr.screenId == "ManagerState")
+ if not (mgr and mgr.screenId == "ManagerState") then
+ while true do coroutine.yield() end
+ end
+
+ -- right once: MODS -> PROFILES
+ U.tap(game, "right")
+ U.wait(6)
+ local rows = mgr:rowsForScreen()
+ check("right lands on the PROFILES tab", mgr.tab == 2)
+ local labels = {}
+ for i, r in ipairs(rows) do labels[i] = r.label end
+ U.log("profile rows:", table.concat(labels, " / "))
+
+ local first = rows[1]
+ check("PROFILE 1 was seeded from the setup that was already live",
+ first and first.profile ~= nil and first.label == "PROFILE 1")
+ check("it is marked active with the ! gutter glyph",
+ first and first.glyph == "!" and opts.activeProfile == "PROFILE 1")
+ -- the near-miss: profileRows grew by EXPORT.. and IMPORT.., so a snapCursor
+ -- or moveCursor still assuming the old three-row tail parks the cursor on the
+ -- wrong row and A fires the wrong action
+ check("the cursor sits on the first profile, not somewhere down the tail",
+ mgr.cursor == 1 and (mgr:focusedRow() or {}).label == "PROFILE 1")
+ local tail = {}
+ for i = #rows - 3, #rows do tail[#tail + 1] = rows[i] and rows[i].label end
+ check("the tail reads SAVE CURRENT AS.. / EXPORT.. / IMPORT.. / [AD-HOC]",
+ tail[1] == "SAVE CURRENT AS.." and tail[2] == "EXPORT.."
+ and tail[3] == "IMPORT.." and (tail[4] or ""):find("AD-HOC", 1, true) ~= nil)
+
+ -- stage a mod off on the MODS tab so applying PROFILE 1 has something to undo.
+ -- Pick one nothing else depends on and that is not experimental, so the toggle
+ -- commits straight away instead of opening the cascade or confirm overlay.
+ local target
+ for _, m in ipairs(status.available or {}) do
+ if m.enabled and not m.experimental then
+ local needed = false
+ for _, other in ipairs(status.available or {}) do
+ for _, spec in ipairs(other.dependencySpecs or {}) do
+ if spec.id == m.id then needed = true end
+ end
+ end
+ if not needed then target = m break end
+ end
+ end
+ check("found an enabled mod with no dependents to toggle", target ~= nil)
+
+ -- refresh() rebuilds status.available, so hold the id and re-look it up
+ -- through mgr.byId rather than keeping the manifest table from before
+ local targetId = target and target.id
+ local function live()
+ return targetId and mgr.byId[targetId] or nil
+ end
+
+ if targetId then
+ U.tap(game, "left")
+ U.wait(6)
+ for _ = 1, 40 do
+ local row = mgr:focusedRow()
+ if row and row.mod and row.mod.id == targetId then break end
+ U.tap(game, "down")
+ U.wait(2)
+ end
+ local row = mgr:focusedRow()
+ check("cursor reached " .. targetId .. " on the MODS tab",
+ row and row.mod and row.mod.id == targetId)
+ U.tap(game, "select")
+ U.wait(6)
+ check("SELECT staged " .. targetId .. " off",
+ mgr.overlay == nil and (live() or {}).enabled == false)
+
+ U.tap(game, "right")
+ U.wait(6)
+ check("back on PROFILES with the cursor on PROFILE 1",
+ mgr.tab == 2 and mgr.cursor == 1
+ and (mgr:focusedRow() or {}).label == "PROFILE 1")
+ U.tap(game, "a")
+ U.wait(10)
+ check("applying PROFILE 1 re-staged " .. targetId,
+ (live() or {}).enabled == true)
+ U.log("footer notice reads:", tostring(mgr.notice))
+ check("the footer says PROFILE STAGED", mgr.notice == "PROFILE STAGED")
+ end
+
+ -- walk down to EXPORT.. by taps, so a cursor that skips shows up here.
+ -- From the top of the list that is every profile row after the first plus
+ -- SAVE CURRENT AS.., i.e. #profiles + 1 presses.
+ local profileCount = 0
+ for _, r in ipairs(mgr:rowsForScreen()) do
+ if r.profile then profileCount = profileCount + 1 end
+ end
+ local wantSteps = profileCount + 1
+ local steps = 0
+ for _ = 1, 10 do
+ local row = mgr:focusedRow()
+ if row and row.exportProfile then break end
+ U.tap(game, "down")
+ U.wait(2)
+ steps = steps + 1
+ end
+ local exportRow = mgr:focusedRow()
+ check(("EXPORT.. is %d presses below PROFILE 1"):format(wantSteps),
+ exportRow ~= nil and exportRow.exportProfile == true and steps == wantSteps)
+ U.tap(game, "a")
+ U.wait(10)
+ U.log("footer notice reads:", tostring(mgr.notice))
+ check("the footer says SAVED PROFILE 1", mgr.notice == "SAVED PROFILE 1")
+
+ local rel = ModProfile.fileFor("PROFILE 1")
+ local info = love.filesystem.getInfo and love.filesystem.getInfo(rel)
+ check(rel .. " exists in the save dir", info ~= nil)
+ if info then
+ local body = love.filesystem.read(rel)
+ local back = ModProfile.decode(body)
+ check("the exported file decodes back to PROFILE 1",
+ back ~= nil and back.name == "PROFILE 1")
+ U.log("wrote", love.filesystem.getSaveDirectory() .. "/" .. rel,
+ info.size and (info.size .. " bytes") or "")
+ end
+
+ -- leave the cursor at the top of the list for whoever takes over
+ for _ = 1, steps do
+ U.tap(game, "up")
+ U.wait(2)
+ end
+ local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
+ U.shot(game, SHOT_DIR .. "/bug593_profiles.png")
+ U.log("captured", SHOT_DIR .. "/bug593_profiles.png")
+
+ U.log("The PROFILES tab is open with the cursor back on PROFILE 1, and the")
+ U.log("steps above already ran once: a mod was switched off, PROFILE 1 put it")
+ U.log("back, and EXPORT.. wrote the file. Redo it by hand: LEFT for MODS,")
+ U.log("SELECT to flip a mod, RIGHT, A on PROFILE 1. The row should carry the")
+ U.log("! glyph and the footer should flash PROFILE STAGED. The failure to")
+ U.log("watch for is the cursor landing a row off after a tab switch or a")
+ U.log("delete, so A on what looks like [AD-HOC] fires EXPORT.. instead.")
+
+ while true do
+ coroutine.yield()
+ end
+end
diff --git a/tests/drivers/oak_dex_rating_bug600_test.lua b/tests/drivers/oak_dex_rating_bug600_test.lua
new file mode 100644
index 00000000..90af7c2b
--- /dev/null
+++ b/tests/drivers/oak_dex_rating_bug600_test.lua
@@ -0,0 +1,214 @@
+-- Eye/ear check on Prof. Oak rating the Pokedex in his lab (#600): with the
+-- Pokedex in hand and 2+ species owned he must lead with the rating branch,
+-- not the line he reads while handing the Pokedex over. pokered
+-- scripts/OaksLab.asm OaksLabOak1Text + engine/events/pokedex_rating.asm.
+-- Do not set POKEPORT_SPEED: fast-forward scales the logic clock only, so
+-- the rating jingle stops landing where the text does.
+-- POKEPORT_DRIVER=tests/drivers/oak_dex_rating_bug600_test.lua POKEPORT_IDENTITY=bug600 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Pokemon = require("src.pokemon.Pokemon")
+ local Commands = require("src.script.Commands")
+ local TextBox = require("src.render.TextBox")
+ local oaksLab = require("data.scripts.oaks_lab")
+ local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
+
+ -- pokered data/maps/objects/OaksLab.asm: OAKSLAB_OAK1 stands at (5, 2)
+ -- facing DOWN behind the table, so the desk talk is from the cell below
+ -- him; the leftover balls sit at (6, 3) / (7, 3) / (8, 3) with the row
+ -- below them walkable (the rival walks it in OaksLabRivalTakePokeBall).
+ local MAP = "OAKS_LAB"
+ local OAK = "OAKSLAB_OAK1"
+ local STAND = { x = 5, y = 3, facing = "up" }
+ local BALL_STAND = { x = 8, y = 4 }
+
+ local function check(label, ok)
+ U.log(ok and "PASS" or "FAIL", label)
+ return ok
+ end
+
+ local function rowAt(rows, i)
+ return rows and rows[i] or {}
+ end
+
+ -- ------------------------------------------------- script rows are there
+ local oak1 = oaksLab.talk.TEXT_OAKSLAB_OAK1
+ local firstRating, ratingLabel, dexRating, firstItemCheck
+ for i, row in ipairs(oak1 or {}) do
+ if row[1] == "check_dex_owned" and not firstRating then firstRating = i end
+ if row[1] == "label" and row[2] == "dex_rating" then ratingLabel = i end
+ if row[1] == "dex_rating" then dexRating = i end
+ if row[1] == "check_item" and not firstItemCheck then firstItemCheck = i end
+ end
+ check("TEXT_OAKSLAB_OAK1 gates on 2+ owned species",
+ firstRating ~= nil and rowAt(oak1, firstRating)[2] == 2)
+ check("it has a dex_rating branch that ends in the rating predef",
+ ratingLabel ~= nil and dexRating ~= nil and dexRating > ratingLabel)
+ -- the branch has to be read before the POKe BALL / around-the-world forks
+ -- or the old wrong line still wins
+ check("the branch is read before the POKe BALL check",
+ firstRating ~= nil and firstItemCheck ~= nil
+ and firstRating < firstItemCheck)
+ check("Commands has check_dex_owned and dex_rating",
+ type(Commands.check_dex_owned) == "function"
+ and type(Commands.dex_rating) == "function")
+
+ -- the leftover-ball beat shares this file (#601 remnant, reported on #600)
+ local ball = oaksLab.talk.TEXT_OAKSLAB_CHARMANDER_POKE_BALL
+ local lastMon = rowAt(ball, 21)[2]
+ check("the leftover ball reads the last-mon line",
+ type(lastMon) == "string" and lastMon:find("last Pok", 1, true) ~= nil)
+ check("and stops there instead of falling into the pre-pick line",
+ rowAt(ball, 22)[1] == "jump" and rowAt(ball, 22)[2] == "end"
+ and rowAt(ball, 23)[2] == "_OaksLabThoseArePokeBallsText")
+ check("the pre-pick jump still lands on that line",
+ rowAt(ball, 4)[1] == "jump_if_false" and rowAt(ball, 4)[2] == 23)
+
+ -- -------------------------------------------------------- text and audio
+ local t = game.data.text
+ local COMING = "_OaksLabOak1HowIsYourPokedexComingText"
+ local WRONG = "_OaksLabOak1PokemonAroundTheWorldText"
+ check(COMING .. " resolves", type(t[COMING]) == "string" and t[COMING] ~= "")
+ check("it is the how-is-it-coming line",
+ type(t[COMING]) == "string" and t[COMING]:find("DEX", 1, true) ~= nil)
+ check(WRONG .. " resolves too (the near miss to tell it from)",
+ type(t[WRONG]) == "string" and t[WRONG] ~= "")
+ check("_DexCompletionText resolves (the seen/owned tally)",
+ type(t._DexCompletionText) == "string")
+ local sfx = game.data.audio and game.data.audio.sfx
+ check("Pokedex_Rating is in the sfx table",
+ sfx ~= nil and sfx.Pokedex_Rating ~= nil)
+
+ local opts = game.save.options or {}
+ if (opts.sfxVol or 0) == 0 then
+ U.log("SFX VOLUME IS 0. The jingle after the rating line is half of this")
+ U.log("check. Raise SFX VOL in OPTION and rerun.")
+ else
+ check("sfx is audible (SFX VOL " .. tostring(opts.sfxVol) .. ")", true)
+ end
+
+ -- ------------------------------------------------------ save at the beat
+ game.save.party = {
+ Pokemon.new(game.data, "CHARMANDER", 8),
+ Pokemon.new(game.data, "PIDGEY", 6),
+ }
+ if not game.save.player.name or game.save.player.name == "" then
+ game.save.player.name = "RED"
+ end
+ local flags = game.save.flags
+ flags.EVENT_GOT_POKEDEX = true
+ flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
+ flags.EVENT_GOT_STARTER = true
+ flags.EVENT_CHOSE_CHARMANDER = true
+ flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
+ flags.EVENT_OAK_GOT_PARCEL = true
+ -- deliberately left unset: with it the rating fires off the converted-save
+ -- path instead, and the owned>=2 gate this issue is about goes untested
+ flags.EVENT_PALLET_AFTER_GETTING_POKEBALLS = nil
+ -- a driver save skips SaveData.new on some paths, so do not assume the dex
+ local dex = game.save.pokedex or { seen = {}, owned = {} }
+ game.save.pokedex = dex
+ dex.owned = { CHARMANDER = true, PIDGEY = true, RATTATA = true }
+ dex.seen = { CHARMANDER = true, PIDGEY = true, RATTATA = true,
+ SPEAROW = true, WEEDLE = true }
+ local owned = 0
+ for _ in pairs(dex.owned) do owned = owned + 1 end
+ check(("Pokedex in hand, %d species owned, no converted-save shortcut")
+ :format(owned),
+ flags.EVENT_GOT_POKEDEX == true and owned >= 2
+ and flags.EVENT_PALLET_AFTER_GETTING_POKEBALLS == nil)
+ -- the tier line is picked by decade in OverworldState:dexRating
+ local tier = ("_DexRatingText_Own%dTo%d")
+ :format(math.floor(owned / 10) * 10, math.floor(owned / 10) * 10 + 9)
+ check(tier .. " resolves for that count", type(t[tier]) == "string")
+
+ -- ----------------------------------------------------- park him at Oak's
+ -- Oak1 is hidden until the parcel beat shows him (data/scripts/story2.lua
+ -- swaps OAKSLAB_OAK2 for OAKSLAB_OAK1 via Commands.show_object), and that
+ -- lives in save.objectToggles, not in the event flags seeded above; the
+ -- rival is long gone by this beat for the same reason
+ local toggles = game.save.objectToggles or {}
+ game.save.objectToggles = toggles
+ toggles[MAP] = toggles[MAP] or {}
+ toggles[MAP][OAK] = true
+ toggles[MAP].OAKSLAB_OAK2 = false
+ toggles[MAP].OAKSLAB_RIVAL = false
+ U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
+ U.wait(10)
+
+ local function oakNpc(ow)
+ for _, n in ipairs(ow and ow.npcs or {}) do
+ if n.def and n.def.name == OAK then return n end
+ end
+ return nil
+ end
+
+ local function facingOak()
+ local ow = game.overworld
+ local o = ow and oakNpc(ow)
+ if not o then return false end
+ local fx, fy = ow.player:facingCell()
+ return ow:npcAtCell(fx, fy) == o
+ end
+
+ local ow = game.overworld
+ local o = ow and oakNpc(ow)
+ check("Oak is loaded on " .. MAP, o ~= nil)
+ if o and not facingOak() then
+ -- a map edit or a mod moved him: {dx, dy, facing} is the offset from Oak
+ -- to the stand cell plus the direction that looks back at him
+ for _, s in ipairs({ { 0, 1, "up" }, { 0, -1, "down" },
+ { 1, 0, "left" }, { -1, 0, "right" } }) do
+ local cx, cy = o.cellX + s[1], o.cellY + s[2]
+ if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
+ U.log(("desk cell (%d,%d) is blocked, standing on"):format(STAND.x, STAND.y),
+ cx, cy, "facing", s[3])
+ U.teleport(game, MAP, cx, cy, s[3])
+ U.wait(10)
+ break
+ end
+ end
+ end
+ check("standing below Oak at the desk", facingOak())
+
+ -- press A here so the log can tell a missing branch from a press that never
+ -- reached Oak; on screen those look the same
+ U.tap(game, "a")
+ U.wait(8)
+ local top = game.stack:top()
+ local isBox = getmetatable(top) == TextBox
+ check("pressing A opened a text box", isBox)
+ if isBox then
+ local shown = {}
+ for _, page in ipairs(top.pages or {}) do
+ for _, line in ipairs(page) do shown[#shown + 1] = line end
+ end
+ local joined = table.concat(shown, " / ")
+ U.log("box reads:", joined)
+ check("Oak opens on the rating branch, not the hand-over line",
+ joined:find("DEX", 1, true) ~= nil
+ and joined:find("wait for", 1, true) == nil)
+ end
+ U.wait(20)
+ U.shot(game, DIR .. "/bug600_oak_rating.png")
+ U.log("captured", DIR .. "/bug600_oak_rating.png")
+
+ U.log("Oak has been talked to; that box on screen is the start of it. He")
+ U.log("says \"Good to see you! How is your #DEX coming? Here, let me take a")
+ U.log("look!\" and runs straight on into the seen/owned tally and PROF.OAK's")
+ U.log("Rating with no press of yours in between, and the jingle sounds only")
+ U.log("after the rating line has printed, not over it (#576). If he instead")
+ U.log("says \"#MON around the world wait for you\" or \"Come see me")
+ U.log("sometimes\", the branch never fired.")
+ U.log("The pad is yours: A again re-runs it any time. For the other half,")
+ U.log(("walk to (%d,%d) and press A up at the leftover ball: Oak turns to")
+ :format(BALL_STAND.x, BALL_STAND.y))
+ U.log("face you and says only \"That's PROF.OAK's last Pokemon!\", with no")
+ U.log("second box about POKe BALLs after it.")
+ U.log("To see the gate itself, console: game.save.pokedex.owned = { CHARMANDER")
+ U.log("= true } and talk again -- one species owned is the around-the-world line.")
+
+ while true do
+ coroutine.yield()
+ end
+end
diff --git a/tests/drivers/oak_speech_letterbox_test.lua b/tests/drivers/oak_speech_letterbox_test.lua
new file mode 100644
index 00000000..e0341e69
--- /dev/null
+++ b/tests/drivers/oak_speech_letterbox_test.lua
@@ -0,0 +1,67 @@
+-- Driver: Oak's intro white field must reach the dialogue box.
+--
+-- The speech fills white over the 160x144 UI canvas, but TextBox docks to the
+-- WINDOW's bottom edge (Renderer:setUIAnchor). In a letterboxed window that
+-- left black between the bottom of the white and the top of the box.
+-- OakSpeech.letterboxWhite fills the voids with the paper shade instead.
+--
+-- Needs a window that actually letterboxes -- an exact multiple of 160x144
+-- has no voids to get wrong -- so it resizes before shooting.
+-- POKEPORT_DRIVER=tests/drivers/oak_speech_letterbox_test.lua lovec .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
+ local OakSpeech = require("src.ui.OakSpeech")
+
+ local function speechUp()
+ for _, s in ipairs(game.stack.states or {}) do
+ if getmetatable(s) == OakSpeech then return s end
+ end
+ return nil
+ end
+
+ -- 1000x700 is not a multiple of 160x144, so the UI blits at 4x (640x576)
+ -- with real bars above/below -- exactly where the seam shows
+ love.window.setMode(1000, 700)
+ U.wait(30)
+
+ U.wait(5)
+ U.tap(game, "start") -- skip the intro movie
+ U.wait(20)
+ U.tap(game, "a") -- title -> menu
+ U.wait(20)
+ U.tap(game, "a") -- NEW GAME
+ U.wait(30)
+
+ local speech
+ for _ = 1, 600 do
+ speech = speechUp()
+ if speech then break end
+ U.wait(2)
+ end
+ if not speech then
+ U.log("FAIL never reached Oak's speech")
+ return
+ end
+ U.log("Oak speech is up; letterboxWhite =", tostring(OakSpeech.letterboxWhite))
+
+ -- page through, shooting a few beats: the pic + box together is the shot
+ -- that shows whether the white reaches the box
+ for i = 1, 4 do
+ for _ = 1, 200 do
+ local top = game.stack:top()
+ if top and top ~= speech and top.done then break end
+ U.tap(game, "a")
+ U.wait(2)
+ if not speechUp() then break end
+ end
+ if not speechUp() then break end
+ U.wait(20)
+ U.shot(game, DIR .. ("/oak_%d.png"):format(i))
+ U.tap(game, "a")
+ U.wait(20)
+ end
+
+ U.log("done")
+ U.wait(30)
+end
diff --git a/tests/drivers/pad_double_input_bug620_test.lua b/tests/drivers/pad_double_input_bug620_test.lua
new file mode 100644
index 00000000..eb41e101
--- /dev/null
+++ b/tests/drivers/pad_double_input_bug620_test.lua
@@ -0,0 +1,126 @@
+-- Hands-on check that a mapped controller sends one press per button (#620).
+-- SDL raises love.gamepadpressed AND love.joystickpressed for the same
+-- physical button on any pad it has a database entry for; the raw fallback in
+-- src/core/Input.lua now stands down for those pads. No pokered behavior is
+-- involved, this is platform input plumbing only. Do not set POKEPORT_SPEED:
+-- fast-forward reorders the logic clock against real input and audio.
+-- POKEPORT_DRIVER=tests/drivers/pad_double_input_bug620_test.lua POKEPORT_IDENTITY=bug620 POKEPORT_TOUCH=0 love .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Pokemon = require("src.pokemon.Pokemon")
+ local Screens = require("src.ui.Screens")
+ local Bag = require("src.inventory.Bag")
+
+ -- pokered data/maps/objects/PalletTown.asm: warp_event 5, 5 is the player's
+ -- house door, so the cell below it is open ground with no object on it.
+ -- Any walkable cell would do, the bag is what is being looked at.
+ local MAP = "PALLET_TOWN"
+ local STAND = { x = 5, y = 6, facing = "down" }
+
+ local function check(label, ok)
+ U.log(ok and "PASS" or "FAIL", label)
+ return ok
+ end
+
+ -- A pad nobody plugged in fails exactly like a pad whose events are being
+ -- dropped, so name the stick before anything else.
+ local pads, mapped = {}, 0
+ if love.joystick then
+ for _, j in ipairs(love.joystick.getJoysticks()) do
+ local isPad = j.isGamepad and j:isGamepad()
+ if isPad then mapped = mapped + 1 end
+ pads[#pads + 1] = j:getName() .. (isPad and " (SDL-mapped)" or " (raw stick)")
+ end
+ end
+ check("a controller is connected", #pads > 0)
+ check("SDL maps at least one of them, which is the pad #620 is about",
+ mapped > 0)
+ if #pads > 0 then U.log("pads:", table.concat(pads, ", ")) end
+
+ -- The gate itself, driven with a stub that answers isGamepad like a mapped
+ -- pad does. Cross-file contract with main.lua's love.joystick* forwarders:
+ -- these three entry points must all ignore a mapped pad, because its press
+ -- already arrived through gamepadpressed this frame. Raw index 9 is SELECT
+ -- in RAW_BUTTON_BINDINGS, which is the button iOS's MFi packing slid the
+ -- D-pad onto.
+ local stub = { isGamepad = function() return true end }
+ local input = game.input
+ input:joystickpressed(stub, 9)
+ input:joystickhat(stub, 1, "l")
+ input:joystickaxis(stub, 1, -0.9)
+ local rawSelect = input.sources.select and input.sources.select["joy:9"]
+ local rawHat = (input.hatDirs[1] or {})[1] ~= nil
+ local rawStick = input.stickDir ~= nil
+ check("a mapped pad's raw button index is ignored", not rawSelect)
+ check("a mapped pad's raw hat is ignored", not rawHat)
+ check("a mapped pad's raw axis is ignored", not rawStick)
+ -- leave nothing held behind if one of those did land (nil = raw stick)
+ if rawSelect then input:joystickreleased(nil, 9) end
+ if rawHat then input:joystickhat(nil, 1, "c") end
+ if rawStick then input:joystickaxis(nil, 1, 0) end
+
+ -- The launcher half runs before Game exists, so this driver can never reach
+ -- it; confirm its copy of the gate is still in the file at least.
+ local src = love.filesystem.read("src/import/RomImporter.lua") or ""
+ check("the launcher keeps its own copy of the gate",
+ src:find("isMappedPad", 1, true) ~= nil)
+
+ -- The swap beep is the audible half of a wrong press.
+ local opts = game.save.options or {}
+ check("sfxVol is not zero (" .. tostring(opts.sfxVol or 7) .. ")",
+ (opts.sfxVol or 7) ~= 0)
+ check("renderer is up", game.renderer ~= nil)
+
+ game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
+ game.save.player.name = "bryan"
+ game.save.inventory = game.save.inventory or {}
+ game.save.inventory.POTION = 3
+ game.save.inventory.ANTIDOTE = 2
+ -- one item can be marked for a swap but never traded with anything, which
+ -- hides half the near-miss
+ check("the bag holds at least two swappable items",
+ #Bag.order(game.save) >= 2)
+
+ U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
+ U.wait(10)
+ local ow = game.overworld
+ if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
+ -- a map edit moved the door: any free walkable neighbour is as good
+ local sides = { { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }
+ for _, s in ipairs(sides) do
+ local cx, cy = STAND.x + s[1], STAND.y + s[2]
+ if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
+ U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
+ cx, cy)
+ U.teleport(game, MAP, cx, cy, STAND.facing)
+ U.wait(10)
+ break
+ end
+ end
+ end
+
+ local list = Screens.push(game, "BagMenu")
+ U.wait(20)
+ -- ListMenu:update checks up/down before SELECT in one elseif chain and the
+ -- bag sets no pageJump, so left is inert there and a stray SELECT in the
+ -- same step is what actually fires (src/ui/ListMenu.lua, swap_items.asm).
+ check("the bag list answers SELECT with a swap",
+ list ~= nil and list.onSelectKey ~= nil)
+ check("left and right do nothing in this list", list ~= nil and not list.pageJump)
+
+ U.tap(game, "down") -- park on the second item so a swap has somewhere to go
+ U.wait(20)
+ U.shot(game, "bug620_bag.png")
+
+ U.log("The bag is open with the cursor on the second item. Press D-pad left")
+ U.log("once on the pad: the cursor should stay put and the list should not")
+ U.log("change at all. If the press also counts as SELECT the item under the")
+ U.log("cursor gets picked up for a swap and draws differently, and a second")
+ U.log("left press trades it with its neighbour with a beep. Up and down move")
+ U.log("normally, B closes the bag. The launcher half of #620 needs a boot")
+ U.log("with data/generated missing, which no driver can reach.")
+
+ while true do
+ coroutine.yield()
+ end
+end
diff --git a/tests/drivers/rebind_joystick_bug632_test.lua b/tests/drivers/rebind_joystick_bug632_test.lua
new file mode 100644
index 00000000..8d347e6c
--- /dev/null
+++ b/tests/drivers/rebind_joystick_bug632_test.lua
@@ -0,0 +1,137 @@
+-- Manual check that a rebind on a real controller actually takes (#632).
+-- LOVE raises love.joystickpressed for EVERY stick, gamepads included, so
+-- Input's fixed raw table used to re-assert the factory A/B/START/SELECT map
+-- underneath a CONTROLS rebind: only a plugged-in pad makes that duplicate
+-- event happen at all, which is why no test can stand in for a hand here.
+-- Rebinding is port-only (gap C2), so there is no pokered file to cite; the
+-- map position below comes from pokered data/maps/objects/PalletTown.asm.
+-- POKEPORT_DRIVER=tests/drivers/rebind_joystick_bug632_test.lua POKEPORT_IDENTITY=bug632 POKEPORT_TOUCH=0 love .
+-- Do not set POKEPORT_SPEED: fast-forward scales the logic clock only, and a
+-- press that lands between two stepped frames is the very thing being judged.
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Screens = require("src.ui.Screens")
+ local BindingsMenu = require("src.ui.BindingsMenu")
+ local Input = require("src.core.Input")
+
+ -- pokered data/maps/objects/PalletTown.asm: warp_event 5, 5 is the player's
+ -- own front door, so the path cell below it is open ground with nothing to
+ -- walk into by accident while the pad is being mashed.
+ local MAP = "PALLET_TOWN"
+ local STAND = { x = 5, y = 6, facing = "down" }
+ local ROW_A = 5 -- BindingsMenu's BUTTONS order: up, down, left, right, A
+
+ local function check(label, ok)
+ U.log(ok and "PASS" or "FAIL", label)
+ return ok
+ end
+
+ -- A pad that SDL has no database entry for and a pad it recognizes behave
+ -- differently on purpose, and only the second one is plugged in here.
+ local sticks = (love.joystick and love.joystick.getJoysticks
+ and love.joystick.getJoysticks()) or {}
+ local recognized = nil
+ for _, js in ipairs(sticks) do
+ if js.isGamepad and js:isGamepad() then recognized = recognized or js end
+ U.log("stick:", js:getName(),
+ (js.isGamepad and js:isGamepad()) and "(SDL gamepad)" or "(raw)")
+ end
+ check("a controller is plugged in", #sticks > 0)
+ check("at least one of them is an SDL-recognized gamepad", recognized ~= nil)
+
+ -- The three halves of the fix that can be read off in-process: the raw
+ -- lookup table applyBindings now builds, the guard that keeps a recognized
+ -- pad off the raw path, and BindingsMenu's raw capture hooks.
+ check("Input:applyBindings built the raw joyBindings table",
+ type(Input.joyBindings) == "table" and Input.joyBindings[1] == "a")
+ check("BindingsMenu has the raw-stick capture hooks",
+ type(BindingsMenu.captureJoy) == "function"
+ and type(BindingsMenu.captureJoyRelease) == "function")
+ check("Game routes joystick presses", type(game.joystickpressed) == "function")
+
+ -- Probe the guard with a stand-in gamepad rather than the live pad, so the
+ -- log says which of the two paths answered without asking for a press yet.
+ local fakePad = { isGamepad = function() return true end }
+ Input:reset()
+ Input:joystickpressed(fakePad, 1)
+ check("a recognized pad's duplicate joystick press is ignored",
+ not Input:isDown("a"))
+ Input:joystickhat(fakePad, 1, "u")
+ check("and its duplicate hat event is ignored too", not Input:isDown("up"))
+ -- a nil joystick is how the raw path is reachable without a raw stick on
+ -- the desk; tests/input_hold_test.lua drives it the same way
+ Input:joystickpressed(nil, 1)
+ check("a stick SDL does not recognize still presses A", Input:isDown("a"))
+ Input:joystickreleased(nil, 1)
+ Input:reset()
+
+ -- Guide and the stick clicks are in no binding table, which is what makes
+ -- them dead buttons rather than something that quietly fires START.
+ check("Guide is unbound", Input.padBindings.guide == nil)
+ check("the left stick click is unbound", Input.padBindings.leftstick == nil)
+
+ U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
+ U.wait(10)
+
+ -- a map edit or a mod could put something on the door path; any free
+ -- neighbour is just as good, the position only has to be somewhere quiet
+ local ow = game.overworld
+ if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
+ for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
+ local cx, cy = STAND.x + d[1], STAND.y + d[2]
+ if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
+ U.log(("cell (%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
+ cx, cy)
+ U.teleport(game, MAP, cx, cy, STAND.facing)
+ U.wait(10)
+ break
+ end
+ end
+ end
+
+ -- Open the screen the same way the START menu does, one push per level, so
+ -- backing out with B lands on OPTION and then on the overworld exactly as
+ -- it would have if the human had walked the menus.
+ Screens.push(game, "OptionsMenu")
+ U.wait(10)
+ Screens.push(game, "BindingsMenu")
+ U.wait(10)
+
+ local menu = game.stack:top()
+ local isBindings = getmetatable(menu) == BindingsMenu
+ check("the CONTROLS screen is open", isBindings)
+
+ if isBindings then
+ for _ = 1, ROW_A - 1 do
+ U.tap(game, "down")
+ U.wait(6)
+ end
+ check("the cursor is parked on the A row",
+ menu.index == ROW_A and menu.items[ROW_A].button.id == "a")
+ U.log("the A row currently reads", menu.items[ROW_A].right)
+ check("the B row reads its defaults too",
+ menu.items[ROW_A + 1] ~= nil
+ and menu.items[ROW_A + 1].right:find("/", 1, true) ~= nil)
+ end
+
+ local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
+ if U.shot(game, SHOT_DIR .. "/bug632_controls.png") then
+ check("the window rendered", true)
+ U.log("captured", SHOT_DIR .. "/bug632_controls.png")
+ else
+ check("the window rendered", false)
+ end
+
+ U.log("CONTROLS is open with the cursor on the A row. Press A to arm it,")
+ U.log("then press and release the pad's B button: the rows should end up")
+ U.log("reading Z/B and X/A. Back out with B twice and open START with the")
+ U.log("pad. The physical B should confirm and the physical A should cancel,")
+ U.log("one action per press. If A both confirms and cancels in the same")
+ U.log("frame (a menu that opens and shuts, or text jumping two pages), the")
+ U.log("raw table is still answering alongside the gamepad map. Guide and a")
+ U.log("left-stick click should do nothing at all out in the overworld.")
+
+ while true do
+ coroutine.yield()
+ end
+end
diff --git a/tests/drivers/rock_tunnel_flash_blink_bug610_test.lua b/tests/drivers/rock_tunnel_flash_blink_bug610_test.lua
new file mode 100644
index 00000000..5893d43a
--- /dev/null
+++ b/tests/drivers/rock_tunnel_flash_blink_bug610_test.lua
@@ -0,0 +1,275 @@
+-- Manual check that FLASH in a dark cave costs one short blink and not a long
+-- white hang (#610). pokered engine/menus/start_sub_menus.asm .flash clears
+-- wMapPalOffset, prints _FlashLightsAreaText over the party menu and only then
+-- runs GBPalWhiteOutWithDelay3, so the cave is already lit when the blink
+-- starts; ADVANCED rebakes its atlas there (#383), which must not ride the blink.
+-- POKEPORT_DRIVER=tests/drivers/rock_tunnel_flash_blink_bug610_test.lua \
+-- POKEPORT_IDENTITY=bug610 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots610 love .
+-- No POKEPORT_SPEED: fast-forward desynchronizes audio and logic ordering.
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Pokemon = require("src.pokemon.Pokemon")
+ local PartyMenu = require("src.ui.PartyMenu")
+ local PaletteFX = require("src.render.PaletteFX")
+ local DIR = os.getenv("SHOT_DIR") or "/tmp/shots610"
+
+ local fails = 0
+ local function check(ok, msg)
+ U.log(ok and "PASS" or "FAIL", msg)
+ if not ok then fails = fails + 1 end
+ return ok
+ end
+
+ -- wall clock, not os.clock: the bake this bug is about blocks the frame, and
+ -- CPU time would hide any of it that waits on the GPU
+ local now = (love and love.timer and love.timer.getTime)
+ and love.timer.getTime or os.clock
+
+ -- one frame, remembering the longest frame seen since the last reset and
+ -- what was drawn on it: "the hitch happened under the party menu" is the
+ -- whole claim of the fix, and it is not visible in a total
+ local worst, worstWhere = 0, "nothing"
+ local function label()
+ local top = game.stack:top()
+ if top == nil then return "nothing" end
+ if getmetatable(top) == PartyMenu then return "the party menu" end
+ if top.pages then return "the FLASH message" end
+ if top == game.overworld then return "the map" end
+ if type(top.t) == "number" and type(top.frames) == "number" then
+ return "the white blink"
+ end
+ return "a menu"
+ end
+ local function step(n)
+ for _ = 1, n do
+ local where = label()
+ local t0 = now()
+ coroutine.yield()
+ local dt = now() - t0
+ if dt > worst then worst, worstWhere = dt, where end
+ end
+ end
+ local function resetWorst() worst, worstWhere = 0, "nothing" end
+
+ -- U.tap spends a frame of its own outside step(), and on the last A of the
+ -- FLASH message that is precisely the frame the work lands on, so press
+ -- through the tracked step instead of losing it
+ local function tap(btn)
+ table.insert(game.input.pressQueue, btn)
+ step(1)
+ game.input.state[btn] = false
+ end
+
+ -- the blink is the transition record's own frame count, not a state class:
+ -- Transition keeps WhiteFlash local, so recognize it by shape (a counter, a
+ -- length, no text pages) the way the stack itself sees it
+ local function isBlink(s)
+ return s ~= nil and s.pages == nil and type(s.t) == "number"
+ and type(s.frames) == "number"
+ end
+
+ game.save.flags.EVENT_GOT_STARTER = true
+ -- every list-time badge gate in PartyMenu: FLASH is the subject, STRENGTH is
+ -- the reference blink (it always prints and always blinks), SURF is there so
+ -- the Route 10 pond can be tried too
+ game.save.inventory.BOULDERBADGE = true
+ game.save.inventory.RAINBOWBADGE = true
+ game.save.inventory.SOULBADGE = true
+ game.save.options = game.save.options or {}
+ game.save.options.textSpeed = 1
+ game.save.options.colors = "redpp" -- ADVANCED, the mode whose rebake is dear
+ PaletteFX.setMode("redpp")
+
+ local function giveMon()
+ local mon = Pokemon.new(game.data, "PIKACHU", 30)
+ mon.moves = { { id = "FLASH", pp = 5 }, { id = "STRENGTH", pp = 15 },
+ { id = "SURF", pp = 15 } }
+ game.save.party = { mon }
+ return mon
+ end
+ giveMon()
+
+ -- ---- what has to be true before any of this can be judged ---------------
+ check(PaletteFX.gbcPack() ~= nil,
+ "the ADVANCED colour pack is installed (without it nothing rebakes "
+ .. "and this driver proves nothing)")
+ check(PaletteFX.usesGbcPack(), "and the active mode is the baking one")
+
+ local darkDef = game.data.field.darkMaps
+ local listed = false
+ for _, m in ipairs(darkDef and darkDef.maps or {}) do
+ if m == "ROCK_TUNNEL_1F" then listed = true end
+ end
+ check(listed, "ROCK_TUNNEL_1F is in field.darkMaps.maps")
+
+ local flashText = game.data.text._FlashLightsAreaText
+ check(type(flashText) == "string" and flashText ~= "",
+ "_FlashLightsAreaText extracted from the ROM")
+ if type(flashText) == "string" then
+ U.log("the message reads:", (flashText:gsub("\n", " / ")))
+ end
+
+ local rec = (game.data.transitions and game.data.transitions.white_flash)
+ or require("src.render.Transition").STYLES.white_flash
+ U.log("white_flash is", tostring(rec and rec.frames), "frames")
+ check(rec and rec.frames and rec.frames <= 12,
+ "the blink is a handful of frames, so anything longer on screen is "
+ .. "work riding it")
+
+ local vol = game.save.options.sfxVol
+ if vol == 0 then
+ U.log("sfxVol is 0: the blink and the menu beeps are silent, turn SFX up",
+ "in OPTION before judging any of this by ear")
+ else
+ U.log("sfxVol =", tostring(vol))
+ end
+
+ -- ---- park just inside the Route 10 entrance -----------------------------
+ -- pokered data/maps/objects/RockTunnel1F.asm: warp_event 15, 3 is the
+ -- Route 10 mouth, so (15, 4) is walkable floor one cell inside it, with the
+ -- warp still one step north for the second half of the check.
+ local MAP, STAND = "ROCK_TUNNEL_1F", { x = 15, y = 4, facing = "down" }
+ local WARP = { x = 15, y = 3 }
+
+ local function enter(lit)
+ game.save.flashLit = lit or nil
+ U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
+ step(12)
+ local ow = game.overworld
+ -- a map edit or a mod can drop a rock on the entrance floor: fall back to
+ -- any walkable neighbour of the warp that is not the warp itself
+ if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
+ local sides = { { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }
+ for _, s in ipairs(sides) do
+ local cx, cy = WARP.x + s[1], WARP.y + s[2]
+ if ow.map:isWalkableCell(cx, cy) and not ow.map:warpAtCell(cx, cy)
+ and not ow:npcAtCell(cx, cy) then
+ U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
+ cx, cy)
+ STAND.x, STAND.y = cx, cy
+ U.teleport(game, MAP, cx, cy, STAND.facing)
+ step(12)
+ ow = game.overworld
+ break
+ end
+ end
+ end
+ return ow
+ end
+
+ local ow = enter(nil)
+ check(ow ~= nil and ow.dark == true, "the tunnel is dark on arrival")
+ check(ow.map:warpAtCell(WARP.x, WARP.y) ~= nil,
+ ("the Route 10 warp is still at (%d, %d), so the walk back out is "
+ .. "reachable"):format(WARP.x, WARP.y))
+ local key = ow.map.renderer and ow.map.renderer.gbcAtlasKey
+ U.log("atlas key, dark:", tostring(key))
+ check(type(key) == "string" and key:find("#dark", 1, true) ~= nil,
+ "and it is drawing the dark bake, not a shaded lit one")
+ check(U.shot(game, DIR .. "/bug610_1_dark.png"),
+ "a frame reached disk, so there is a window to watch")
+
+ -- ---- run the moment once, watching where the cost lands -----------------
+ -- START -> POKeMON -> the mon -> FLASH, looked up by row rather than counted
+ -- so a mod that inserts a row cannot silently pick something else
+ tap("start")
+ step(12)
+ local menu = game.stack:top()
+ local row
+ for i, it in ipairs(menu and menu.items or {}) do
+ if tostring(it.label):upper():find("MON", 1, true) then row = i break end
+ end
+ check(row ~= nil, "the START menu lists POKeMON")
+ for _ = 2, (row or 1) do tap("down"); step(2) end
+ tap("a")
+ step(12)
+ local pm = game.stack:top()
+ check(getmetatable(pm) == PartyMenu, "POKeMON opens the party menu")
+ tap("a")
+ step(8)
+ local sub
+ for i, it in ipairs((pm and pm.subItems) or {}) do
+ if it.action == "flash" then sub = i break end
+ end
+ check(sub ~= nil, "FLASH is listed for this PIKACHU on a dark floor")
+ for _ = 2, (sub or 1) do tap("down"); step(2) end
+ tap("a")
+ step(6)
+ check(game.stack:top() ~= nil and game.stack:top().pages ~= nil,
+ "FLASH puts its message up")
+ check(ow.dark == true, "the tunnel is still dark while that message types")
+
+ -- drain the text, then watch every frame from the last A to the map coming
+ -- back: this is the window the bug filled with a white hang
+ resetWorst()
+ local blinkStart, blinkEnd, litAtBlink, keyAtBlink
+ for _ = 1, 400 do
+ local top = game.stack:top()
+ if top and top.pages then
+ tap("a")
+ step(3)
+ elseif isBlink(top) then
+ if not blinkStart then
+ blinkStart = now()
+ litAtBlink = (ow.dark == false)
+ keyAtBlink = ow.map.renderer and ow.map.renderer.gbcAtlasKey
+ end
+ step(1)
+ else
+ if blinkStart and not blinkEnd then blinkEnd = now() end
+ break
+ end
+ end
+ step(20)
+
+ check(blinkStart ~= nil, "the blink ran")
+ -- the fix itself, stated as an invariant: OverworldState:setDark drops every
+ -- resident map and rebakes this one, and that has to be finished before the
+ -- first white frame is drawn (start_sub_menus.asm .flash order)
+ check(litAtBlink == true,
+ "the cave was already lit on the blink's first frame, not lit by it")
+ check(type(keyAtBlink) == "string"
+ and keyAtBlink:find("#dark", 1, true) == nil,
+ "and the lit atlas was already baked and bound by then")
+ if blinkStart and blinkEnd then
+ U.log(("the white was on screen for %.2f s"):format(blinkEnd - blinkStart))
+ end
+ U.log(("the longest single frame in the whole sequence was %.2f s, drawn "
+ .. "over %s"):format(worst, worstWhere))
+ check(worstWhere ~= "the white blink",
+ "the expensive frame was not one of the white ones")
+ check(ow.dark == false, "the tunnel is lit now")
+ U.shot(game, DIR .. "/bug610_2_lit.png")
+
+ -- ---- and it stays lit across the Route 10 round trip --------------------
+ -- pokered data/maps/objects/Route10.asm: warp_event 8, 17 is the north
+ -- tunnel mouth, so (8, 18) is the grass one step below it
+ U.teleport(game, "ROUTE_10", 8, 18, "up")
+ step(12)
+ ow = enter(true)
+ check(ow.dark == false, "coming back in from Route 10 the cave is still lit")
+ key = ow.map.renderer and ow.map.renderer.gbcAtlasKey
+ check(type(key) == "string" and key:find("#dark", 1, true) == nil,
+ "on the lit bake, with no second rebake to blink through")
+ U.shot(game, DIR .. "/bug610_3_relit.png")
+
+ U.log(fails == 0 and "all #610 machine checks passed"
+ or (fails .. " #610 machine check(s) FAILED, read up"))
+ U.log("shots in", DIR)
+
+ -- ---- hand the pad over, dark, FLASH unused ------------------------------
+ giveMon()
+ enter(nil)
+
+ U.log("You are back in the dark tunnel in ADVANCED colours with FLASH unused:")
+ U.log("START, POKeMON, A, FLASH. The message should end over the party list, any")
+ U.log("hitch should happen with that list still on screen, and then a blink about")
+ U.log("as short as STRENGTH's on the same menu hands back a lit cave. A white that")
+ U.log("sits noticeably longer than the STRENGTH one means the rebake is riding the")
+ U.log("blink again. Then walk up to the ladder, out to Route 10 and back in: lit,")
+ U.log("no second blink.")
+
+ while true do
+ coroutine.yield()
+ end
+end
diff --git a/tests/drivers/route1_seam_pacing_bug487_test.lua b/tests/drivers/route1_seam_pacing_bug487_test.lua
new file mode 100644
index 00000000..981b0e15
--- /dev/null
+++ b/tests/drivers/route1_seam_pacing_bug487_test.lua
@@ -0,0 +1,128 @@
+-- Hands-on check for #487: walk pacing after a map-connection seam.
+-- Parks the player in Viridian City's south exit column (pokered
+-- data/maps/objects/ViridianCity.asm, border block $f, south connection to
+-- Route 1) so DOWN alone crosses into ROUTE_1, then gives input back.
+-- POKEPORT_DRIVER=tests/drivers/route1_seam_pacing_bug487_test.lua POKEPORT_IDENTITY=bug487 POKEPORT_TOUCH=0 love .
+-- Never add POKEPORT_SPEED: fast-forward scales the logic clock only, so it
+-- reorders audio against logic and hides the very wobble being judged.
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local FrameCap = require("src.core.FrameCap")
+
+ local function check(label, ok)
+ U.log(ok and "PASS" or "FAIL", label)
+ return ok
+ end
+
+ -- Viridian City is 20x18 blocks == 40x36 cells; the exit path south runs
+ -- down column cellX 20 to the bottom row cellY 35, where crossConnection
+ -- (src/world/OverworldController.lua) swaps in ROUTE_1 and calls
+ -- FixedStep:discardCatchup -- the only caller, which is why #487 showed up
+ -- at route/city seams and nowhere else. Warps take the Transition path
+ -- instead, so a Viridian door is the control case.
+ local MAP, EXIT_X, START_Y = "VIRIDIAN_CITY", 20, 31
+
+ local maps = game.data.maps
+ local vc, r1 = maps and maps[MAP], maps and maps.ROUTE_1
+ check("VIRIDIAN_CITY and ROUTE_1 are both in data/generated/maps.lua",
+ vc ~= nil and r1 ~= nil)
+ check("Viridian's south connection still points at Route 1",
+ vc ~= nil and vc.connections and vc.connections.south
+ and vc.connections.south.map == "ROUTE_1")
+
+ -- the door the human re-checks afterwards: ViridianCity.asm warp_event
+ -- 21, 15 -> VIRIDIAN_SCHOOL_HOUSE
+ local doorOk = false
+ for _, w in ipairs((vc and vc.warps) or {}) do
+ if w.x == 21 and w.y == 15 then doorOk = true end
+ end
+ check("the school house door at (21, 15) is still a warp", doorOk)
+
+ -- Judging pacing needs real frame times, and main.lua's driver branch feeds
+ -- Game:update a pinned 1/60 so the accumulator never sees display jitter.
+ -- Under POKEPORT_DRIVER this is expected to read FAIL: the position below
+ -- is still useful, the verdict has to come from a plain run.
+ check("the running build feeds FixedStep real frame times",
+ os.getenv("POKEPORT_DRIVER") == nil)
+ check("no fast-forward multiplier is set",
+ (tonumber(os.getenv("POKEPORT_SPEED")) or 1) == 1
+ and (game.save.options.speed or 1) == 1)
+ check("a window is up to watch (this is a visual call)",
+ love.window ~= nil and love.window.isOpen and love.window.isOpen())
+ U.log("MAX FPS reads", FrameCap.label(game.save.options.fpsCap),
+ "and vsync is",
+ (love.window.getVSync and tostring(love.window.getVSync())) or "unknown")
+
+ -- Probe copy of the timing module so the reseed can be asserted without
+ -- disturbing the live loop this driver is running inside. discardCatchup
+ -- still zeroes accum on the way out; the fix is that the frame absorbing
+ -- the hitch hands it back mid-step instead of parked on a step boundary.
+ local probe = loadfile("src/core/FixedStep.lua")()
+ probe:init(function() end)
+ probe:discardCatchup()
+ probe:update(0.25)
+ check("an absorbed hitch frame leaves the accumulator mid-step",
+ probe.accum > probe.STEP * 0.25 and probe.accum < probe.STEP * 0.75)
+
+ -- a party and the starter flag so the overworld behaves like a real save
+ game.save.flags.EVENT_GOT_STARTER = true
+ if #game.save.party == 0 then
+ local Pokemon = require("src.pokemon.Pokemon")
+ table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5))
+ end
+
+ U.teleport(game, MAP, EXIT_X, START_Y, "down")
+ local ow = game.overworld
+
+ -- a map edit or a mod could block column 20; walk down whichever nearby
+ -- column is clear all the way to the bottom row instead
+ local function columnIsClear(x)
+ for y = START_Y, 35 do
+ if not ow.map:isWalkableCell(x, y) then return false end
+ end
+ return true
+ end
+ local col = EXIT_X
+ if not columnIsClear(col) then
+ for _, dx in ipairs({ -1, 1, -2, 2, -3, 3 }) do
+ if columnIsClear(EXIT_X + dx) then col = EXIT_X + dx break end
+ end
+ U.log(("column %d is blocked, using column %d"):format(EXIT_X, col))
+ U.teleport(game, MAP, col, START_Y, "down")
+ ow = game.overworld
+ end
+ check("the exit column is walkable from here to the south edge",
+ columnIsClear(col))
+
+ -- cross it once so the seam is known reachable from this cell, then come
+ -- back and leave the player a few steps short of it
+ local crossed = false
+ for _ = 1, 200 do
+ table.insert(game.input.pressQueue, "down")
+ game.input.state.down = true
+ coroutine.yield()
+ if ow.map.id == "ROUTE_1" and ow.player.cellY >= 0 then crossed = true break end
+ end
+ game.input.state.down = false
+ U.wait(5)
+ check("holding DOWN from that cell reaches Route 1", crossed)
+
+ U.teleport(game, MAP, col, START_Y, "down")
+ U.wait(10)
+
+ U.log(("Standing in Viridian City at cell (%d, %d), four steps above the"):format(col, START_Y))
+ U.log("Route 1 seam. Hold DOWN and keep holding it for about ten seconds")
+ U.log("once you are on Route 1: past the single hitch on the crossing frame")
+ U.log("the scroll should stay dead even, a pixel a frame, the same as the")
+ U.log("walk through the city. Broken, the sprite stalls for a frame then")
+ U.log("jumps two pixels, again and again, and never settles down. The easy")
+ U.log("misread is grading the crossing frame itself: that one hitch is")
+ U.log("expected and this change does not touch it, so watch the seconds")
+ U.log("after. Then step into the school house door at (21, 15) to confirm")
+ U.log("warps look the way they always did, and run the walk once more with")
+ U.log("MAX FPS set to 30 in Options, where the wobble also showed up.")
+
+ while true do
+ coroutine.yield()
+ end
+end
diff --git a/tests/drivers/safari_zone_bug540_test.lua b/tests/drivers/safari_zone_bug540_test.lua
new file mode 100644
index 00000000..b84869a7
--- /dev/null
+++ b/tests/drivers/safari_zone_bug540_test.lua
@@ -0,0 +1,248 @@
+-- Driver: the Safari Zone entrance walk and the safari battle HUD (#540).
+-- Paying runs SafariZoneEntranceAutoWalk (pokered scripts/SafariZoneGate.asm),
+-- so the two gate steps are charged and the counter reads 500/500; leaving
+-- early is a queued script, not a box drawn over a black transition; and the
+-- ball count belongs in the battle menu (engine/battle/core.asm:2074-2079).
+-- No POKEPORT_SPEED: fast-forward desyncs the audio clock from the walk.
+-- POKEPORT_DRIVER=tests/drivers/safari_zone_bug540_test.lua POKEPORT_IDENTITY=bug540 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Pokemon = require("src.pokemon.Pokemon")
+ local Commands = require("src.script.Commands")
+ local BattleState = require("src.battle.BattleState")
+
+ -- pokered data/maps/objects/SafariZoneGate.asm: warps 3,0 / 4,0 lead to
+ -- SAFARI_ZONE_CENTER 1 / 2, and scripts/SafariZoneGate.asm's
+ -- .PlayerNextToSafariZoneWorker1CoordsArray is (3,2)/(4,2), so walking up
+ -- the right-hand column from the south warp crosses the join trigger and
+ -- lands on the warp the auto-walk takes.
+ local GATE = "SAFARI_ZONE_GATE"
+ local CENTER = "SAFARI_ZONE_CENTER"
+ local START = { x = 4, y = 4, facing = "up" }
+ local TRIGGER = { x = 4, y = 2 }
+ local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
+
+ local function check(label, ok)
+ U.log(ok and "PASS" or "FAIL", label)
+ return ok
+ end
+
+ -- ---- machine-checkable preconditions ------------------------------------
+ -- a renamed text key, a moved warp or a missing encounter table all end as
+ -- "nothing happened", which is indistinguishable from the bug on screen
+ local t = game.data.text
+ local KEYS = {
+ "_SafariZoneGateSafariZoneWorker1WouldYouLikeToJoinText",
+ "_SafariZoneGateSafariZoneWorker1ThatllBe500PleaseText",
+ "_SafariZoneGateSafariZoneWorker1GoodLuckText",
+ "_SafariZoneGateSafariZoneWorker1LeavingEarlyText",
+ "_SafariZoneGateSafariZoneWorker1ReturnSafariBallsText",
+ "_SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText",
+ }
+ local missingText = {}
+ for _, k in ipairs(KEYS) do
+ if type(t[k]) ~= "string" or t[k] == "" then missingText[#missingText + 1] = k end
+ end
+ check("every gate text key resolves to a string", #missingText == 0)
+ if #missingText > 0 then U.log(" missing:", table.concat(missingText, ", ")) end
+
+ -- the leaving-early script is queued rather than pushed, so every opcode it
+ -- uses has to exist or the queue runs off the end in silence
+ local OPS = { "ask", "jump_if_false", "show_text", "set_field",
+ "move_player", "jump", "label", "warp" }
+ local missingOps = {}
+ for _, op in ipairs(OPS) do
+ if type(Commands[op]) ~= "function" then missingOps[#missingOps + 1] = op end
+ end
+ check("the queued leaving-early script's commands all exist", #missingOps == 0)
+ if #missingOps > 0 then U.log(" missing:", table.concat(missingOps, ", ")) end
+
+ local gateDef = game.data.maps[GATE]
+ local centerDef = game.data.maps[CENTER]
+ local northWarp
+ for _, w in ipairs(gateDef and gateDef.warps or {}) do
+ if w.x == TRIGGER.x and w.y == 0 then northWarp = w end
+ end
+ check(GATE .. " has the north warp at (4, 0) into " .. CENTER,
+ northWarp ~= nil and northWarp.destMap == CENTER)
+ local dest = centerDef and centerDef.warps and centerDef.warps[2]
+ check(CENTER .. " warp 2 is the arrival cell",
+ dest ~= nil and dest.x == 15 and dest.y == 25)
+ if dest then U.log(" arrival cell:", dest.x, dest.y) end
+
+ local enc = game.data.encounters and game.data.encounters[CENTER]
+ local slots = enc and enc.grass and enc.grass.slots
+ check(CENTER .. " has a grass encounter table", slots ~= nil and #slots > 0)
+
+ -- the auto-walk out of the gate ends on a warp, so the door sfx is part of
+ -- what the moment is judged on
+ local sfx = game.data.audio and game.data.audio.sfx
+ local vol = game.save.options and game.save.options.sfxVol
+ check("Go_Outside sfx is loaded", sfx ~= nil and sfx.Go_Outside ~= nil)
+ check(("sfx volume is %s, the door sound is audible"):format(tostring(vol)),
+ love.audio ~= nil and (vol or 0) > 0)
+ if (vol or 0) == 0 then
+ U.log(" raise SFX VOL in OPTION or the walk out will be silent")
+ end
+ U.log("BATTLE LAYOUT is currently",
+ (game.save.options and game.save.options.battleLayout) == "wide"
+ and "WIDE" or "OG")
+
+ -- ---- entry: pay, then watch him walk himself in --------------------------
+ -- FAST text: the join spiel plus the payment text run five pages, and at
+ -- the MEDIUM default (TextBox drawChars reads save.options.textSpeed) the
+ -- mash loop below runs out of taps before the auto-walk is ever queued
+ game.save.options = game.save.options or {}
+ game.save.options.textSpeed = 1
+ game.save.money = 3000 -- the ¥500 fee, plus change for a few more runs
+ if #game.save.party == 0 then
+ game.save.party = {
+ Pokemon.new(game.data, "NIDORINO", 25),
+ Pokemon.new(game.data, "PIDGEY", 14),
+ Pokemon.new(game.data, "SANDSHREW", 18),
+ }
+ U.log("party was empty; added three mons so the pokeball row has content")
+ end
+ game.save.safari = nil
+
+ U.teleport(game, GATE, START.x, START.y, START.facing)
+ U.wait(15)
+ local ow = game.overworld
+ check("the start menu box is gated off in the gate itself",
+ ow ~= nil and ow.inSafariStepZone ~= nil and not ow:inSafariStepZone())
+
+ -- a map edit that blocks the right-hand column would stop the trigger ever
+ -- firing; fall back to the other trigger cell's column
+ if ow and ow.map and not ow.map:isWalkableCell(TRIGGER.x, TRIGGER.y) then
+ U.log(("(%d, %d) is not walkable; using the left trigger column instead")
+ :format(TRIGGER.x, TRIGGER.y))
+ U.teleport(game, GATE, 3, START.y, START.facing)
+ U.wait(10)
+ ow = game.overworld
+ end
+
+ U.log("He is about to pay and walk in on his own; the screen is yours after.")
+ for _ = 1, 12 do
+ U.hold(game, "up", 8)
+ if game.stack:top() ~= game.overworld then break end
+ end
+ check("stepping up in front of the worker opened the join prompt",
+ game.stack:top() ~= game.overworld)
+
+ -- mash A through the join text, the YES/NO box (YES is the default index)
+ -- and the payment text; the auto-walk starts on its own once that closes
+ for _ = 1, 150 do
+ U.tap(game, "a")
+ U.wait(5)
+ local cur = game.overworld
+ if cur and cur.map and cur.map.id == CENTER then break end
+ end
+ U.wait(45)
+
+ ow = game.overworld
+ local st = game.save.safari
+ check("the safari game started", st ~= nil)
+ check("he warped through on his own and is in " .. CENTER,
+ ow ~= nil and ow.map and ow.map.id == CENTER)
+ if ow and ow.map and ow.map.id == CENTER then
+ check(("he arrived at the warp cell (%d, %d)")
+ :format(ow.player.cellX, ow.player.cellY),
+ ow.player.cellX == 15 and ow.player.cellY == 25)
+ end
+ if st then
+ check(("the counter reads %d/500 with %d balls")
+ :format(st.steps or -1, st.balls or -1),
+ st.steps == 500 and st.balls == 30)
+ end
+ check("the start menu box is live now that he is inside",
+ ow ~= nil and ow.inSafariStepZone and ow:inSafariStepZone())
+
+ U.tap(game, "start")
+ U.wait(20)
+ if U.shot(game, SHOT_DIR .. "/bug540_start_menu.png") then
+ U.log("captured", SHOT_DIR .. "/bug540_start_menu.png")
+ end
+ U.tap(game, "b")
+ U.wait(15)
+
+ -- ---- the safari battle HUD ----------------------------------------------
+ local function firstGrassCell(map)
+ for y = 0, (map.heightCells or 0) - 1 do
+ for x = 0, (map.widthCells or 0) - 1 do
+ if map:isGrassCell(x, y) and map:isWalkableCell(x, y) then return x, y end
+ end
+ end
+ end
+
+ ow = game.overworld
+ if ow and ow.map and not ow.map:isGrassCell(ow.player.cellX, ow.player.cellY) then
+ local gx, gy = firstGrassCell(ow.map)
+ if gx then
+ U.log("stepping over to the grass at", gx, gy)
+ U.teleport(game, CENTER, gx, gy, "up")
+ U.wait(10)
+ else
+ U.log("FAIL no walkable grass cell found on " .. CENTER)
+ end
+ end
+
+ local function liveBattle()
+ for _, s in ipairs(game.stack.states or {}) do
+ if getmetatable(s) == BattleState then return s end
+ end
+ return nil
+ end
+
+ local DIRS = { "up", "down", "left", "right" }
+ local battle
+ for i = 1, 400 do
+ U.hold(game, DIRS[(i - 1) % #DIRS + 1], 10)
+ battle = liveBattle()
+ if battle then break end
+ end
+ check("a safari battle started", battle ~= nil and battle.safari ~= nil)
+ if battle and battle.enemy and battle.enemy.mon then
+ U.log(" encounter:", battle.enemy.mon.species, "at level",
+ battle.enemy.mon.level)
+ end
+ if not battle then
+ U.log("no encounter after 400 steps -- walk into the grass yourself")
+ end
+
+ -- catch the "Wild X appeared!" beat first, then clear it: the count is
+ -- judged in both, absent over the field and present in the menu
+ U.wait(60)
+ if U.shot(game, SHOT_DIR .. "/bug540_battle_intro.png") then
+ U.log("captured", SHOT_DIR .. "/bug540_battle_intro.png")
+ end
+ -- the cry and the slide run on their own clocks, so press until the phase
+ -- flips rather than guessing a frame count
+ for _ = 1, 60 do
+ if battle and battle.phase == "menu" then break end
+ U.tap(game, "a")
+ U.wait(10)
+ end
+ check("the BALL/BAIT/THROW ROCK/RUN menu is up",
+ battle ~= nil and battle.phase == "menu")
+ U.wait(15)
+ if U.shot(game, SHOT_DIR .. "/bug540_battle_menu.png") then
+ U.log("captured", SHOT_DIR .. "/bug540_battle_menu.png")
+ end
+
+ -- ---- hand off ------------------------------------------------------------
+ U.log("Controls are yours. The count belongs inside the menu, reading")
+ U.log("\"BALLx 30\" on one line with the cursor to its left, and nothing")
+ U.log("floating over the field above the party pokeball row. Watch for the")
+ U.log("30 landing a cell left and colliding with the x, or a cell right and")
+ U.log("leaving a gap before BAIT; toggle BATTLE LAYOUT in OPTION and check")
+ U.log("WIDE too, where the old 15x4 panel used to sit at tile (23,7).")
+ U.log("Then RUN, walk back down onto the south warp: \"Leaving early?\"")
+ U.log("should sit over the drawn gate interior, worker and counter and")
+ U.log("shelves visible, not a black screen. Answer NO to bounce back in and")
+ U.log("do it again, or YES for the return-balls text and a walk down to")
+ U.log("(4,3) below the worker, rather than being left on the warp at (4,0).")
+
+ while true do
+ coroutine.yield()
+ end
+end
diff --git a/tests/drivers/school_blackboard_bug591_test.lua b/tests/drivers/school_blackboard_bug591_test.lua
new file mode 100644
index 00000000..d197d7cc
--- /dev/null
+++ b/tests/drivers/school_blackboard_bug591_test.lua
@@ -0,0 +1,180 @@
+-- Manual check on the Viridian School blackboard headings layout (#591).
+-- pokered engine/events/hidden_events/school_blackboard.asm .blackboardLoop draws
+-- a 12x8 box and places StatusAilmentText1 at hlcoord 1,2 and StatusAilmentText2 at
+-- hlcoord 6,2, with ViridianSchoolBlackboardText2 (data/text/text_2.asm, `done`)
+-- still on screen under it. Logic half: tests/parity_viridian_school_blackboard_bug503.lua.
+-- POKEPORT_DRIVER=tests/drivers/school_blackboard_bug591_test.lua POKEPORT_IDENTITY=bug591 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . (no POKEPORT_SPEED: fast-forward desyncs audio/logic ordering)
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local TextBox = require("src.render.TextBox")
+ -- data/scripts/init.lua, not src/script/MapScripts.lua: OverworldController
+ -- requires it lazily on the first map load, so the flavor registrations are
+ -- not in place yet when the driver starts
+ local MapScripts = require("data.scripts.init")
+
+ local MAP = "VIRIDIAN_SCHOOL_HOUSE"
+ local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
+
+ -- pokered data/events/hidden_events.asm:164, hidden_events_for
+ -- VIRIDIAN_SCHOOL_HOUSE: hidden_text_predef 3, 0 is the blackboard on the
+ -- north wall. That cell is the wall itself, so the stand cell is the
+ -- walkable one below it, looking up (data/maps/objects/ViridianSchoolHouse.asm
+ -- puts the two warps at (2,7)/(3,7) and no object in the way).
+ local BOARD = { x = 3, y = 0 }
+ local STAND = { x = 3, y = 1, facing = "up" }
+
+ local function check(label, ok)
+ U.log(ok and "PASS" or "FAIL", label)
+ return ok
+ end
+
+ local hooks = MapScripts.get(MAP)
+ check(MAP .. " registers an onInteract hook",
+ hooks ~= nil and type(hooks.onInteract) == "function")
+ -- a renamed extractor label reads on screen as "A did nothing", which is the
+ -- #503 failure and not the layout question this driver is about
+ for _, key in ipairs({ "_ViridianSchoolBlackboardText1",
+ "_ViridianSchoolBlackboardText2", "_ViridianBlackboardSleepText",
+ "_ViridianBlackboardPoisonText", "_ViridianBlackboardPrlzText",
+ "_ViridianBlackboardBurnText", "_ViridianBlackboardFrozenText" }) do
+ local s = game.data.text[key]
+ check(key .. " resolves to a string", type(s) == "string" and s ~= "")
+ end
+
+ local opts = game.save.options or {}
+ U.log("audio device present:", love.audio ~= nil,
+ " SFX VOL (0-7):", tostring(opts.sfxVol))
+ if not love.audio or opts.sfxVol == 0 then
+ U.log("WARNING: sfx output is off, so the Press_AB beep on every heading pick",
+ "will be silent; raise SFX VOL in OPTION first")
+ end
+
+ U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
+ U.wait(10)
+ local ow = game.overworld
+ check("map " .. MAP .. " loaded", ow ~= nil and ow.map ~= nil)
+ check("the blackboard cell is solid, as the wall it hangs on",
+ ow ~= nil and not ow.map:isWalkableCell(BOARD.x, BOARD.y))
+
+ -- a map edit or a mod could move the board: any walkable neighbour will do,
+ -- {dx, dy, facing} is the offset from the board plus the look back at it
+ local SIDES = {
+ { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
+ }
+ local function facingBoard()
+ local p = game.overworld.player
+ local fx, fy = p:facingCell()
+ return fx == BOARD.x and fy == BOARD.y
+ end
+ if not facingBoard() then
+ for _, s in ipairs(SIDES) do
+ local cx, cy = BOARD.x + s[1], BOARD.y + s[2]
+ if game.overworld.map:isWalkableCell(cx, cy)
+ and not game.overworld:npcAtCell(cx, cy) then
+ U.log(("(%d,%d) was not faced from the stand cell, standing on")
+ :format(BOARD.x, BOARD.y), cx, cy, "facing", s[3])
+ U.teleport(game, MAP, cx, cy, s[3])
+ U.wait(10)
+ break
+ end
+ end
+ end
+ check("standing where the blackboard is faced", facingBoard())
+
+ local function boxText(box)
+ local out = {}
+ for _, page in ipairs(box.pages or {}) do
+ for _, line in ipairs(page) do out[#out + 1] = line end
+ end
+ return table.concat(out, " / ")
+ end
+
+ -- the headings list is the flavor script's own StatusBoard, not a TextBox and
+ -- not src/ui/Menu.lua (which has no second column), so identify it by .labels
+ local function board()
+ local top = game.stack:top()
+ if top and top.labels and top.selection then return top end
+ return nil
+ end
+ local function reachBoard()
+ for _ = 1, 60 do
+ if board() then return true end
+ U.tap(game, "a")
+ U.wait(8)
+ end
+ return false
+ end
+
+ U.tap(game, "a")
+ U.wait(30)
+ local top = game.stack:top()
+ check("A on the blackboard opened a text box", getmetatable(top) == TextBox)
+ if getmetatable(top) == TextBox then
+ U.log("intro box reads:", boxText(top))
+ check("it is _ViridianSchoolBlackboardText1",
+ boxText(top):find("blackboard", 1, true) ~= nil)
+ end
+
+ check("the intro clears into the headings list", reachBoard())
+ local b = board()
+ if b then
+ local labels = {}
+ for i, label in ipairs(b.labels) do labels[i] = (label:gsub("^%s+", "")) end
+ U.log("list rows:", table.concat(labels, " / "))
+ check("SLP/PSN/PAR then BRN/FRZ/QUIT, in ViridianBlackboardStatusPointers order",
+ table.concat(labels, "/") == "SLP/PSN/PAR/BRN/FRZ/QUIT")
+ -- the whole point of #591: the prompt box is held open underneath rather
+ -- than dismissed before the list goes up
+ local under = game.stack.states[#game.stack.states - 1]
+ check("the prompt box is still on the stack under the list",
+ getmetatable(under) == TextBox and under.stay ~= nil)
+ if getmetatable(under) == TextBox then
+ U.log("prompt under the list reads:", boxText(under))
+ end
+ U.shot(game, SHOT_DIR .. "/bug591_blackboard_list.png")
+ end
+
+ if b then
+ U.tap(game, "right")
+ U.wait(10)
+ check("RIGHT keeps the row and lands on BRN (wMenuItemOffset 3)",
+ b:selection() == 4)
+ U.shot(game, SHOT_DIR .. "/bug591_right_column.png")
+ U.tap(game, "down")
+ U.wait(10)
+ check("DOWN inside the right column reaches FRZ", b:selection() == 5)
+ U.shot(game, SHOT_DIR .. "/bug591_right_down.png")
+
+ U.tap(game, "a")
+ U.wait(30)
+ top = game.stack:top()
+ check("A on FRZ prints its blurb", getmetatable(top) == TextBox)
+ if getmetatable(top) == TextBox then
+ U.log("FRZ blurb reads:", boxText(top))
+ check("it is _ViridianBlackboardFrozenText, the entry RIGHT+DOWN points at",
+ boxText(top):find("frozen", 1, true) ~= nil)
+ end
+ U.shot(game, SHOT_DIR .. "/bug591_frz_blurb.png")
+
+ check("the blurb loops back to the list (jp .blackboardLoop)", reachBoard())
+ -- wCurrentMenuItem / wMenuItemOffset are never cleared inside the loop, so
+ -- the cursor comes back where it was left
+ check("the cursor comes back on FRZ, not reset to SLP",
+ board() ~= nil and board():selection() == 5)
+ U.shot(game, SHOT_DIR .. "/bug591_back_to_list.png")
+ U.log("screenshots in", SHOT_DIR)
+ end
+
+ U.log("The list on screen is the moment: a 12x8 box in the top-left with two")
+ U.log("columns, SLP/PSN/PAR and BRN/FRZ/QUIT on the same three rows, and the")
+ U.log("\"Which heading do you want to read?\" box still up at the bottom with no")
+ U.log("blinking arrow in it. The near-miss to watch for is the prompt vanishing")
+ U.log("(or blinking) once the list appears, and a cursor sitting on the S of SLP")
+ U.log("instead of the blank in front of it. RIGHT/LEFT jump columns keeping the")
+ U.log("row, UP/DOWN stop at the ends without wrapping, and QUIT or B should drop")
+ U.log("both boxes and let you walk again with no stale dialogue left behind.")
+
+ while true do
+ coroutine.yield()
+ end
+end
diff --git a/tests/drivers/seafoam_holes_bug599_test.lua b/tests/drivers/seafoam_holes_bug599_test.lua
new file mode 100644
index 00000000..b931f45d
--- /dev/null
+++ b/tests/drivers/seafoam_holes_bug599_test.lua
@@ -0,0 +1,180 @@
+-- Eye/ear check that the Seafoam Islands floor holes drop the PLAYER, not just
+-- the boulders (#599). Each floor's script sets wDungeonWarpDestinationMap and
+-- calls IsPlayerOnDungeonWarp over its SeafoamNHolesCoords list (pokered
+-- scripts/SeafoamIslands1F.asm, B2F.asm); the landing cells come from
+-- data/maps/special_warps.asm DungeonWarpData. The data half is asserted in
+-- tests/parity_seafoam_holes.lua.
+-- POKEPORT_DRIVER=tests/drivers/seafoam_holes_bug599_test.lua POKEPORT_IDENTITY=bug599 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
+-- No POKEPORT_SPEED: it scales the logic clock only while audio runs on its own
+-- real-time accumulator in Game:update, so it desyncs the ordering being judged.
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
+ local Sound = require("src.core.Sound")
+ local Pokemon = require("src.pokemon.Pokemon")
+ local mapScripts = require("data.scripts.init")
+
+ -- Hole cells from pokered scripts/SeafoamIslands1F.asm Seafoam1HolesCoords
+ -- (17,6)/(24,6) and scripts/SeafoamIslandsB2F.asm Seafoam3HolesCoords
+ -- (19,6)/(22,6); the landings are the DungeonWarpData rows those floors
+ -- index. data/generated/maps.lua agrees: those four cells carry CAVERN $22
+ -- and are walkable, which is why the fall is an onStep and not a collision.
+ -- The B2F pair is here because its landing on B3F is water: the arrival has
+ -- to come up already surfing, not standing on the sea.
+ local FALLS = {
+ { from = "SEAFOAM_ISLANDS_1F", hx = 17, hy = 6,
+ to = "SEAFOAM_ISLANDS_B1F", lx = 18, ly = 7, water = false,
+ shot = DIR .. "/bug599_1f_to_b1f.png" },
+ { from = "SEAFOAM_ISLANDS_B2F", hx = 19, hy = 6,
+ to = "SEAFOAM_ISLANDS_B3F", lx = 18, ly = 7, water = true,
+ shot = DIR .. "/bug599_b2f_to_b3f.png" },
+ }
+
+ local function check(label, ok)
+ U.log(ok and "PASS" or "FAIL", label)
+ return ok
+ end
+
+ -- ---- what the eye and the ear cannot check ------------------------------
+ -- Silence is half the claim (a hole is not a door), and at SFX 0 a dead
+ -- audio path and a fixed bug sound exactly alike.
+ local opts = game.save.options or {}
+ local sfxVol = opts.sfxVol or 7
+ if sfxVol == 0 then
+ U.log("FAIL SFX volume is 0, so the door chime this fall must NOT play")
+ U.log(" could not play anyway. Set SFX to 7 in OPTION and rerun.")
+ end
+ check(("SFX volume %d"):format(sfxVol), sfxVol > 0)
+
+ for _, f in ipairs(FALLS) do
+ local hooks = mapScripts.get(f.from)
+ check(f.from .. " has a hole onStep hook", hooks ~= nil and hooks.onStep ~= nil)
+ end
+
+ -- a party so a wild encounter on the B3F water is a battle to back out of
+ -- rather than a blackout that throws the hand-off away
+ if not game.save.party or #game.save.party == 0 then
+ game.save.party = { Pokemon.new(game.data, "LAPRAS", 40),
+ Pokemon.new(game.data, "SNORLAX", 45) }
+ end
+ game.save.moveFlags = game.save.moveFlags or {}
+
+ -- Every SFX cue raised during a fall. Go_Inside / Go_Outside is the door
+ -- chime WarpFound2 .indoorMaps skips for a warp pad or hole, and an ear can
+ -- miss it under a fade if the fade happens to be busy.
+ local cues, watching = {}, false
+ local realPlay = Sound.play
+ Sound.play = function(data, key, ...)
+ if watching then cues[#cues + 1] = tostring(key) end
+ return realPlay(data, key, ...)
+ end
+
+ -- Stand one cell south of the hole and walk north into it. If a map edit
+ -- moved the floor out from under that cell, fall back to any walkable
+ -- neighbour that is not itself a hole, and walk from there instead.
+ local SIDES = { { 0, 1, "up" }, { 0, -1, "down" },
+ { 1, 0, "left" }, { -1, 0, "right" } }
+
+ local function standFor(f)
+ local map = game.overworld and game.overworld.map
+ local sameMap = map and map.id == f.from
+ for _, s in ipairs(SIDES) do
+ local cx, cy = f.hx + s[1], f.hy + s[2]
+ local free = not sameMap
+ if sameMap then
+ free = map:isWalkableCell(cx, cy)
+ and (not map.warpPadOrHoleAt or map:warpPadOrHoleAt(cx, cy) ~= "hole")
+ end
+ if free then return cx, cy, s[3] end
+ end
+ return f.hx, f.hy + 1, "up"
+ end
+
+ local function runFall(f)
+ -- teleport onto the floor first so the map is loaded and standFor can read
+ -- its collision, then park on the approach cell that map actually offers
+ U.teleport(game, f.from, f.hx, f.hy + 1, "up")
+ U.wait(10)
+ local sx, sy, dir = standFor(f)
+ if sx ~= f.hx or sy ~= f.hy + 1 then
+ U.log(("approach cell (%d, %d) is blocked, walking in from")
+ :format(f.hx, f.hy + 1), sx, sy, "facing", dir)
+ U.teleport(game, f.from, sx, sy, dir)
+ U.wait(10)
+ end
+ local ow = game.overworld
+ check(("standing on %s (%d, %d) facing %s"):format(f.from, sx, sy, dir),
+ ow ~= nil and ow.map.id == f.from
+ and ow.player.cellX == sx and ow.player.cellY == sy)
+ check(("the hole at (%d, %d) is a walkable CAVERN tile, not a warp event")
+ :format(f.hx, f.hy),
+ ow ~= nil and ow.map:isWalkableCell(f.hx, f.hy)
+ and ow.map:warpPadOrHoleAt(f.hx, f.hy) == "hole")
+
+ cues, watching = {}, true
+ U.hold(game, dir, 18)
+
+ -- snapshot the very first frame the destination map is live: on B3F the
+ -- current starts dragging the player the moment the step completes, so a
+ -- later read would report where the water took them, not where they landed
+ local landed
+ for _ = 1, 300 do
+ local o = game.overworld
+ if o and o.map.id == f.to and not landed then
+ landed = { x = o.player.cellX, y = o.player.cellY,
+ facing = o.player.facing, surfing = o.player.surfing }
+ end
+ if landed and not o.transitioning then break end
+ U.wait(1)
+ end
+ U.wait(20)
+ watching = false
+
+ check("walking into the hole left " .. f.from, landed ~= nil)
+ if not landed then
+ U.log(" the player is still on", game.overworld and game.overworld.map.id)
+ return
+ end
+ check(("landed on %s at (%d, %d), wanted (%d, %d)")
+ :format(f.to, landed.x, landed.y, f.lx, f.ly),
+ landed.x == f.lx and landed.y == f.ly)
+ check("facing carried through the fall (" .. tostring(landed.facing) .. ")",
+ landed.facing == dir)
+ if f.water then
+ -- the landing is sea, so setMap's CheckForceBikeOrSurf pass
+ -- (OverworldState:checkForcedMovement) has to mount SURF on arrival
+ check("arrived already surfing on the B3F water", landed.surfing == true)
+ end
+ local doors = {}
+ for _, c in ipairs(cues) do
+ if c == "Go_Inside" or c == "Go_Outside" then doors[#doors + 1] = c end
+ end
+ check("no door chime over the fall (heard: " ..
+ (#cues > 0 and table.concat(cues, ", ") or "nothing") .. ")",
+ #doors == 0)
+ U.shot(game, f.shot)
+ U.log("captured", f.shot)
+ end
+
+ for _, f in ipairs(FALLS) do runFall(f) end
+
+ Sound.play = realPlay
+
+ -- hand back to real input on the 1F approach cell, one step short of the hole
+ U.teleport(game, FALLS[1].from, FALLS[1].hx, FALLS[1].hy + 1, "up")
+ U.wait(10)
+
+ U.log("Both falls have already run; press up from where you are standing to")
+ U.log("do the 1F one again. It should be a plain fade to black and back with")
+ U.log("the player on B1F at (18,7) still facing up, no door chime, and no")
+ U.log("step out of a doorway on the far side. The near miss to watch for is")
+ U.log("landing one cell off, on the boulder's own spot from field.seafoam")
+ U.log("landsAt, which strands you inside the rock; and dropping onto the B3F")
+ U.log("sea standing up instead of surfing, which shows as the player on top")
+ U.log("of the water until the next step. The other three holes are 1F (24,6),")
+ U.log("B1F (18,6)/(23,6) and B2F (22,6) if you want to walk the cascade down.")
+
+ while true do
+ coroutine.yield()
+ end
+end
diff --git a/tests/drivers/touch_layout_bug633_test.lua b/tests/drivers/touch_layout_bug633_test.lua
new file mode 100644
index 00000000..3c074030
--- /dev/null
+++ b/tests/drivers/touch_layout_bug633_test.lua
@@ -0,0 +1,197 @@
+-- Manual check of the per-orientation touch layouts and button resize (#633):
+-- one shared positions table meant a drag in landscape moved the portrait pad,
+-- and there was no size setting at all. No pokered counterpart: the on-screen
+-- pad is a port-only affordance (Xelu CC0 art, src/core/TouchControls.lua), the
+-- Game Boy had physical buttons, so the coords here come from the editor itself.
+-- POKEPORT_DRIVER=tests/drivers/touch_layout_bug633_test.lua POKEPORT_IDENTITY=bug633 POKEPORT_TOUCH=1 POKEPORT_VERSION=red love .
+-- Leave POKEPORT_SPEED unset: this one is dragged by hand, at real time, and
+-- fast-forward desyncs the driver's frames from the frames that draw the editor.
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local TouchControls = require("src.core.TouchControls")
+ local SafeArea = require("src.core.SafeArea")
+ local Editor = require("src.ui.TouchControlsEditor")
+
+ local function check(label, ok)
+ U.log(ok and "PASS" or "FAIL", label)
+ return ok
+ end
+
+ -- The editor lives in launcher mode, before bootGame, and a POKEPORT_DRIVER
+ -- run boots straight past the launcher (main.lua: POKEPORT_DRIVER counts as a
+ -- scripted run). So put the window in landscape ourselves, then open the
+ -- editor and take over the frame + pointer handlers it normally gets from
+ -- main.lua's TouchEditor branch. We must not set that global: love.update
+ -- returns early on it and the driver coroutine would stop being resumed.
+ local ww, wh = love.graphics.getDimensions()
+ if ww <= wh then
+ love.window.setMode(1024, 768, { resizable = true })
+ U.wait(4)
+ ww, wh = love.graphics.getDimensions()
+ end
+ check("the window rendered, and it is wider than tall (landscape)",
+ ww > 0 and wh > 0 and ww > wh)
+
+ -- Pre-#633 options.lua kept one top-level positions table. It has to seed
+ -- both orientations as separate copies, or the very bug being fixed comes
+ -- straight back through the upgrade path.
+ local legacy = TouchControls.normalizeConfig({
+ positions = { dpad = { x = 0.2, y = 0.8 } },
+ })
+ local lp, ll = legacy.layouts.portrait, legacy.layouts.landscape
+ check("an old single-layout options.lua seeds both orientations",
+ lp.positions ~= nil and ll.positions ~= nil
+ and lp.positions.dpad.x == 0.2 and ll.positions.dpad.x == 0.2)
+ check("and never as one shared table", lp.positions ~= ll.positions)
+ check("a file with no size setting reads back as 100%",
+ lp.scale == 1 and ll.scale == 1)
+
+ -- The near miss this guards: a scale that grows the art but not the default
+ -- centers, which parks A and B half off the right edge at 160%. Both the
+ -- widths and the centers come out of defaultLayout, so compare them.
+ local base = TouchControls.defaultLayout(ww, wh, 0, 0, 1)
+ local big = TouchControls.defaultLayout(ww, wh, 0, 0, TouchControls.SCALE_MAX)
+ local grew, moved, inside = true, true, true
+ for _, name in ipairs(TouchControls.CONTROLS) do
+ local b, g = base[name], big[name]
+ if not (b and g) then grew = false break end
+ if g.w <= b.w then grew = false end
+ if g.cx == b.cx and g.cy == b.cy then moved = false end
+ if g.cx - g.w / 2 < -0.5 or g.cx + g.w / 2 > ww + 0.5
+ or g.cy - g.w / 2 < -0.5 or g.cy + g.w / 2 > wh + 0.5 then
+ inside = false
+ end
+ end
+ check("at 160% every control is wider than at 100%", grew)
+ check("the default centers move with the art, not just the art", moved)
+ check("and nothing at 160% hangs off the window", inside)
+
+ -- The editor owns TouchControls while it is open (load re-inits it and turns
+ -- preview on), so open it before touching live state.
+ local reopened = 0
+ local function openEditor()
+ Editor.load({ onClose = function()
+ -- Done persists into options.lua; reopening straight away reloads from
+ -- that file, which is the whole point of the check the reporter runs.
+ reopened = reopened + 1
+ U.log("Done saved the layouts; the editor reopened from options.lua"
+ .. " (reopen #" .. reopened .. ").")
+ openEditor()
+ end })
+ end
+ openEditor()
+
+ local prevDraw = love.draw
+ local prevPressed, prevReleased, prevMoved =
+ love.mousepressed, love.mousereleased, love.mousemoved
+ local prevKey = love.keypressed
+ love.draw = function()
+ Editor.update(love.timer.getDelta())
+ Editor.draw()
+ -- main.lua's own love.draw owns the driver frame capture; taking the
+ -- handler over means taking that with it, or U.shot waits out its spin
+ -- and reports a file that was never written
+ if game.capturePath then
+ local path = game.capturePath
+ game.capturePath = nil
+ love.graphics.captureScreenshot(function(imagedata)
+ local f = io.open(path, "wb")
+ if f then
+ f:write(imagedata:encode("png"):getString())
+ f:close()
+ end
+ end)
+ end
+ end
+ love.mousepressed = function(x, y, button) Editor.mousepressed(x, y, button or 1) end
+ love.mousereleased = function(x, y, button) Editor.mousereleased(x, y, button or 1) end
+ love.mousemoved = function(x, y) Editor.mousemoved(x, y) end
+ love.keypressed = function(key) Editor.keypressed(key) end
+ local function restore()
+ love.draw = prevDraw
+ love.mousepressed, love.mousereleased = prevPressed, prevReleased
+ love.mousemoved, love.keypressed = prevMoved, prevKey
+ end
+
+ check("the overlay art loaded (every control has an image)",
+ TouchControls:ensureImages() and TouchControls.img ~= nil
+ and TouchControls.img.dpad ~= nil and TouchControls.img.a ~= nil
+ and TouchControls.img.b ~= nil and TouchControls.img.start ~= nil
+ and TouchControls.img.select ~= nil)
+ U.wait(4) -- chrome hit rects exist only after the panel has drawn once
+
+ check("the editor is in the landscape bucket",
+ TouchControls.orientation == "landscape")
+ local plus, minus = Editor.rects.sizeUp, Editor.rects.sizeDown
+ if not check("the size card drew its -/+ buttons", plus ~= nil and minus ~= nil) then
+ U.log("Nothing to click: the size card never drew, so the rest is by hand.")
+ restore()
+ while true do coroutine.yield() end
+ end
+
+ -- Drag the d-pad to mid-screen through the editor's own touch path. No
+ -- yields inside the drag: Editor.update follows the live mouse while a drag
+ -- is open, and a rendered frame in the middle would snap it to the cursor.
+ local ox, oy, sw, sh = SafeArea.rect()
+ local L = TouchControls:layout()
+ local target = { x = ox + sw * 0.5, y = oy + sh * 0.5 }
+ -- normalizeConfig seeds BOTH buckets (a pre-#633 file's top-level positions
+ -- become separate copies), so portrait.positions may already be a table
+ -- here; the invariant is that the landscape drag does not touch it
+ local portraitBefore = {}
+ for name, p in pairs(TouchControls.layouts.portrait.positions or {}) do
+ portraitBefore[name] = { x = p.x, y = p.y }
+ end
+ if L.dpad then
+ Editor.touchpressed("probe", L.dpad.cx, L.dpad.cy)
+ Editor.touchmoved("probe", target.x, target.y)
+ Editor.touchreleased("probe", target.x, target.y)
+ else
+ -- fallback if the d-pad zone is missing (a mod replaced the layout):
+ -- write the same normalized center the drag would have written
+ TouchControls:setControlCenter("dpad", target.x, target.y)
+ end
+ U.wait(2)
+ local dragged = TouchControls.layouts.landscape.positions
+ check("the drag landed in the landscape bucket",
+ dragged ~= nil and dragged.dpad ~= nil)
+ local portraitAfter = TouchControls.layouts.portrait.positions or {}
+ local portraitSame = true
+ for name, p in pairs(portraitAfter) do
+ local b = portraitBefore[name]
+ if not b or b.x ~= p.x or b.y ~= p.y then portraitSame = false end
+ portraitBefore[name] = nil
+ end
+ if next(portraitBefore) ~= nil then portraitSame = false end
+ check("the portrait bucket is still untouched by it", portraitSame)
+
+ for _ = 1, 3 do
+ Editor.mousepressed(plus.x + plus.w / 2, plus.y + plus.h / 2, 1)
+ U.wait(2)
+ end
+ check("three taps on + read back as 130% landscape",
+ math.abs((TouchControls.layouts.landscape.scale or 1) - 1.3) < 0.001)
+ check("portrait is still at 100%",
+ math.abs((TouchControls.layouts.portrait.scale or 1) - 1) < 0.001)
+
+ local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
+ if U.shot(game, SHOT_DIR .. "/bug633_landscape.png") then
+ U.log("captured", SHOT_DIR .. "/bug633_landscape.png")
+ end
+
+ U.log("The editor on screen is live and landscape: the d-pad has already been")
+ U.log("dragged to mid-screen and + tapped three times, so the card should read")
+ U.log("\"Button size (Landscape)\" at 130% with the d-pad, A/B and START/SELECT")
+ U.log("all grown together and all still fully on screen. Drag the window taller")
+ U.log("than wide and the heading should flip to Portrait with the pad back in")
+ U.log("the default bottom corners at 100%; move it there and the landscape one")
+ U.log("must not budge when you drag back. Done saves and reopens the editor, so")
+ U.log("both rotations should come back exactly as you left them. If the portrait")
+ U.log("pad shows up already moved or already at 130% the moment you rotate, the")
+ U.log("two orientations are still sharing one bucket; if A and B slide off the")
+ U.log("right edge as the size climbs, the size grew the art but not the centers.")
+
+ while true do
+ coroutine.yield()
+ end
+end
diff --git a/tests/drivers/ui_layout_option_test.lua b/tests/drivers/ui_layout_option_test.lua
new file mode 100644
index 00000000..93124e55
--- /dev/null
+++ b/tests/drivers/ui_layout_option_test.lua
@@ -0,0 +1,53 @@
+-- Driver: UI LAYOUT centered vs dynamic, same moment shot twice.
+--
+-- Only visible when the window letterboxes, so it resizes first: at an exact
+-- multiple of 160x144 there is nowhere for a docked element to dock TO.
+-- Shoots the overworld dialogue box and the START menu, the two pieces the
+-- option moves.
+-- POKEPORT_DRIVER=tests/drivers/ui_layout_option_test.lua lovec .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
+
+ love.window.setMode(1000, 700)
+ U.wait(30)
+
+ -- straight into the overworld, no intro
+ U.teleport(game, "PALLET_TOWN", 5, 6, "down")
+ U.wait(40)
+
+ local function shootBoth(tag)
+ -- START menu (Menu anchors "topright")
+ U.tap(game, "start")
+ U.wait(45)
+ U.shot(game, DIR .. ("/uilayout_%s_startmenu.png"):format(tag))
+ U.tap(game, "b")
+ U.wait(30)
+
+ -- a dialogue box (TextBox anchors "bottom"): read the sign by the door
+ local TextBox = require("src.render.TextBox")
+ game.stack:push(TextBox.new(game, "UI LAYOUT check:\nthis box.", function() end))
+ for _ = 1, 400 do
+ local top = game.stack:top()
+ if top and top.done then break end
+ U.wait(2)
+ end
+ U.wait(30)
+ U.shot(game, DIR .. ("/uilayout_%s_textbox.png"):format(tag))
+ game.stack:pop()
+ U.wait(20)
+ end
+
+ game.save.options.uiLayout = "centered"
+ U.log("UI LAYOUT = centered (the default)")
+ U.wait(20)
+ shootBoth("centered")
+
+ game.save.options.uiLayout = "dynamic"
+ U.log("UI LAYOUT = dynamic")
+ U.wait(20)
+ shootBoth("dynamic")
+
+ U.log("done")
+ U.wait(20)
+end
diff --git a/tests/drivers/wardens_house_yesno_bug645_test.lua b/tests/drivers/wardens_house_yesno_bug645_test.lua
new file mode 100644
index 00000000..c117d7e4
--- /dev/null
+++ b/tests/drivers/wardens_house_yesno_bug645_test.lua
@@ -0,0 +1,103 @@
+-- Driver: #645 Warden's House YES/NO.
+--
+-- WardensHouseWardenText prints Gibberish1, calls YesNoChoice, and answers
+-- with Gibberish2 on yes / Gibberish3 on no (scripts/WardensHouse.asm). The
+-- port printed the question and ended the script, so the box just closed.
+--
+-- Runs the conversation twice against the live script: once answering YES,
+-- once walking the cursor down to NO, shooting the YES/NO box and each reply.
+-- POKEPORT_DRIVER=tests/drivers/wardens_house_yesno_bug645_test.lua \
+-- POKEPORT_SPEED=2 lovec .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
+
+ -- record what the script actually prints, so the log is checkable on its
+ -- own and does not depend on reading the shots
+ local Commands = require("src.script.Commands")
+ local shown = {}
+ local origShow = Commands.show_text
+ -- forward every argument: the 4th is extraOpts, which is how Commands.ask
+ -- passes its `choice` callback -- drop it and the YES/NO box never appears
+ Commands.show_text = function(ctx, textId, ...)
+ shown[#shown + 1] = textId
+ U.log("text:", textId)
+ return origShow(ctx, textId, ...)
+ end
+
+ local ChoiceBox = require("src.ui.ChoiceBox")
+ local function choiceUp()
+ local top = game.stack:top()
+ return top and getmetatable(top) == ChoiceBox and top or nil
+ end
+
+ -- mash A until the YES/NO box is up, then hand it back
+ local function talkUntilChoice()
+ U.tap(game, "a")
+ for _ = 1, 600 do
+ local box = choiceUp()
+ if box then return box end
+ U.tap(game, "a")
+ U.wait(3)
+ end
+ return nil
+ end
+
+ local function talkDone()
+ for _ = 1, 600 do
+ if game.stack:top() == game.overworld then return true end
+ U.tap(game, "a")
+ U.wait(3)
+ end
+ return false
+ end
+
+ -- the warden stands at (2,3); face him from the tile below
+ U.teleport(game, "WARDENS_HOUSE", 2, 4, "up")
+ -- Lead-in: this is meant to be WATCHED, not just logged. At SPEED=2 the
+ -- driver runs two iterations per rendered frame, so a wait of N is N/120
+ -- seconds on screen -- long enough here to find the window before anything
+ -- happens. Every beat below is held the same way.
+ U.log("watch now: talking to the WARDEN in 5 seconds")
+ U.wait(600)
+ U.shot(game, DIR .. "/warden_0_before.png")
+
+ -- ---------------------------------------------------------------- YES
+ shown = {}
+ local box = talkUntilChoice()
+ if not box then
+ U.log("FAIL no YES/NO box appeared after the gibberish line")
+ else
+ U.log("YES/NO box is up, cursor on", box.index == 1 and "YES" or "NO")
+ U.wait(360) -- hold the box on screen for 3s
+ U.shot(game, DIR .. "/warden_1_yesno_box.png")
+ U.tap(game, "a") -- take the default, YES
+ U.wait(360)
+ U.shot(game, DIR .. "/warden_2_yes_reply.png")
+ end
+ talkDone()
+ U.log("YES branch printed:", table.concat(shown, ","))
+ U.log("now the same conversation again, answering NO")
+ U.wait(360)
+
+ -- ----------------------------------------------------------------- NO
+ shown = {}
+ box = talkUntilChoice()
+ if not box then
+ U.log("FAIL no YES/NO box on the second talk")
+ else
+ U.wait(240)
+ U.tap(game, "down") -- walk the cursor onto NO, on screen
+ U.wait(360)
+ U.log("cursor now on", box.index == 1 and "YES" or "NO")
+ U.shot(game, DIR .. "/warden_3_yesno_on_no.png")
+ U.tap(game, "a")
+ U.wait(360)
+ U.shot(game, DIR .. "/warden_4_no_reply.png")
+ end
+ talkDone()
+ U.log("NO branch printed:", table.concat(shown, ","))
+
+ U.wait(360)
+ Commands.show_text = origShow
+end
diff --git a/tests/drivers/warp_fade_bug607_test.lua b/tests/drivers/warp_fade_bug607_test.lua
new file mode 100644
index 00000000..4d81b4cd
--- /dev/null
+++ b/tests/drivers/warp_fade_bug607_test.lua
@@ -0,0 +1,158 @@
+-- Manual check that a door warp fades in whole shades, not a dissolve (#607).
+-- PlayMapChangeSound tail-calls GBFadeOutToBlack (pokered home/overworld.asm:703),
+-- four palette writes held eight frames each (home/fade.asm:43-67), so the veil
+-- is a four-step staircase across 32 frames, never a tween. Numbers are pinned
+-- in tests/mod_graphics_tests.lua; this puts eyes on the screen.
+-- SHOT_DIR=/tmp/shots607 POKEPORT_DRIVER=tests/drivers/warp_fade_bug607_test.lua POKEPORT_IDENTITY=bug607 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
+-- Do not set POKEPORT_SPEED: fast-forward scales the logic clock only, so the
+-- door SFX and the fade steps stop lining up and the staircase is unjudgeable.
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Transition = require("src.render.Transition")
+ local Sound = require("src.core.Sound")
+ local DIR = os.getenv("SHOT_DIR") or "/tmp/shots607"
+
+ -- pokered data/maps/objects/PalletTown.asm: warp_event 5, 5, REDS_HOUSE_1F, 1.
+ -- The mat itself is the warp cell, so the approach is the walkable cell below
+ -- it, facing up: one step north fires the door.
+ local MAP = "PALLET_TOWN"
+ local DEST = "REDS_HOUSE_1F"
+ local DOOR = { x = 5, y = 5 }
+ local STAND = { x = 5, y = 6, facing = "up" }
+
+ local failures = 0
+ local function check(label, ok)
+ if not ok then failures = failures + 1 end
+ U.log(ok and "PASS" or "FAIL", label)
+ return ok
+ end
+
+ -- the door row itself: a renamed destination or a moved warp reads on screen
+ -- as "the fade never happened", same as the bug did
+ local def = game.data.maps and game.data.maps[MAP]
+ local door = nil
+ for _, w in ipairs(def and def.warps or {}) do
+ if w.destMap == DEST then door = w end
+ end
+ check(MAP .. " carries a warp row to " .. DEST, door ~= nil)
+ if door then
+ DOOR.x, DOOR.y = door.x, door.y
+ STAND.x, STAND.y = door.x, door.y + 1
+ check(("the door sits at (%d, %d), as in PalletTown.asm"):format(DOOR.x, DOOR.y),
+ DOOR.x == 5 and DOOR.y == 5)
+ end
+
+ -- the fade is silent without these two: the step SFX is what tells the ear
+ -- where frame 0 of the 32 was
+ local sfx = game.data.audio and game.data.audio.sfx or {}
+ check("Go_Inside and Go_Outside resolve as sfx keys",
+ sfx.Go_Inside ~= nil and sfx.Go_Outside ~= nil)
+ local vol = game.save.options and game.save.options.sfxVol or 7
+ if vol == 0 then
+ U.log("sfxVol is 0: the door SFX will be SILENT, raise it in OPTION first")
+ end
+ check(("sfx volume %d"):format(vol), vol > 0)
+
+ -- the record and the staircase it drives, before any of it is on screen
+ local fade = Transition.new(game)
+ check("the warp fade runs 32 frames with no fade in",
+ fade.frames == 32 and (fade.framesIn or 0) == 0)
+ fade.phase = "out"
+ local want = { { 0, 0 }, { 7, 0 }, { 8, 1 / 3 }, { 15, 1 / 3 },
+ { 16, 2 / 3 }, { 23, 2 / 3 }, { 24, 1 }, { 31, 1 } }
+ local staircase = true
+ for _, w in ipairs(want) do
+ fade.t = w[1]
+ if fade:alpha() ~= w[2] then staircase = false end
+ end
+ check("its alpha holds four shades, eight frames apiece", staircase)
+
+ U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
+ U.wait(10)
+ local ow = game.overworld
+ if not (ow.map:isWalkableCell(STAND.x, STAND.y)) then
+ -- a map edit or a mod blocked the approach: any walkable neighbour of the
+ -- mat still steps onto it. {dx, dy, facing} is the offset from the door to
+ -- the stand cell plus the direction that walks back onto it.
+ local sides = { { 0, 1, "up" }, { 0, -1, "down" },
+ { 1, 0, "left" }, { -1, 0, "right" } }
+ for _, s in ipairs(sides) do
+ local cx, cy = DOOR.x + s[1], DOOR.y + s[2]
+ if ow.map:isWalkableCell(cx, cy) then
+ U.log(("(%d, %d) is blocked, approaching from"):format(STAND.x, STAND.y),
+ cx, cy, "facing", s[3])
+ STAND.x, STAND.y, STAND.facing = cx, cy, s[3]
+ U.teleport(game, MAP, cx, cy, s[3])
+ U.wait(10)
+ break
+ end
+ end
+ end
+ check("player is standing on the doorstep",
+ ow.player.cellX == STAND.x and ow.player.cellY == STAND.y)
+
+ -- walk in, shooting across the fade on the same cadence door_test.lua uses,
+ -- so the two runs' frames line up side by side
+ U.hold(game, STAND.facing, 20)
+ check("first frame of the transition captured",
+ U.shot(game, DIR .. "/door_1_mid.png"))
+ for i = 2, 6 do
+ U.wait(6)
+ U.shot(game, DIR .. ("/door_%d_transition.png"):format(i))
+ end
+ U.wait(40)
+ check("the door landed us in " .. DEST, ow.map.id == DEST)
+
+ -- walking back out is the same fade, and sampling it frame by frame (no
+ -- screenshots in this pass, those cost frames) says whether the veil really
+ -- plateaus or is creeping between the steps
+ local function liveFade()
+ local top = game.stack:top()
+ return getmetatable(top) == Transition and top or nil
+ end
+ local seen, order = {}, {}
+ for _ = 1, 140 do
+ table.insert(game.input.pressQueue, "down")
+ game.input.state.down = true
+ local live = liveFade()
+ if live then
+ local a = live:alpha()
+ if not seen[a] then seen[a] = 0; order[#order + 1] = a end
+ seen[a] = seen[a] + 1
+ elseif #order > 0 then
+ break
+ end
+ coroutine.yield()
+ end
+ game.input.state.down = false
+ U.wait(20)
+ U.shot(game, DIR .. "/door_9_back_outside.png")
+
+ local shades = {}
+ for _, a in ipairs(order) do
+ shades[#shades + 1] = ("%.2f x%d"):format(a, seen[a])
+ end
+ U.log("shades held on the way out:", table.concat(shades, ", "))
+ check("the exit fade shows four shades and no more", #order == 4)
+ local held = true
+ for _, a in ipairs(order) do
+ if seen[a] < 7 then held = false end
+ end
+ check("each shade holds its eight frames", held)
+ check("we are back outside on " .. MAP, ow.map.id == MAP)
+
+ -- hand the doorstep back so this can be re-triggered by hand
+ U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
+ U.log(failures == 0 and "checks clean" or (failures .. " check(s) failed above"))
+ U.log("Red is on his own doorstep facing the door; hold Up to go in, Down to")
+ U.log("come back out, as often as you like. The town should stay fully lit for")
+ U.log("about eight frames after the door sound, then drop in three whole shades")
+ U.log("roughly an eighth of a second apart and sit on solid black for the last")
+ U.log("eighth before the house cuts in. If it instead dims smoothly, or is")
+ U.log("already grey the instant you step on the mat and the map swaps under a")
+ U.log("half-lit veil, that is the dissolve #607 was about, not the fade.")
+
+ while true do
+ coroutine.yield()
+ end
+end
diff --git a/tests/engine/advanced_palette_map.lua b/tests/engine/advanced_palette_map.lua
new file mode 100644
index 00000000..67cddd10
--- /dev/null
+++ b/tests/engine/advanced_palette_map.lua
@@ -0,0 +1,81 @@
+-- ADVANCED (redpp) must use pokered-gbc's GEN 1 species->palette map.
+--
+-- pokered-gbc's data/pokemon/palettes.asm carries TWO tables:
+--
+-- IF GEN_2_GRAPHICS db PAL_BULBASAUR / PAL_SQUIRTLE / ... (per species)
+-- ELSE db PAL_GREENMON ; BULBASAUR
+-- db PAL_CYANMON ; SQUIRTLE (Gen 1's own)
+-- ENDC
+--
+-- The per-species palettes are authored for GEN 2 sprite art, whose shading
+-- puts different regions on different 2bpp shades. This port extracts Gen 1
+-- pics from the ROM, so the ELSE branch is the matching one.
+--
+-- data/palettes_gbc.lua had imported the GEN_2_GRAPHICS table, which pointed
+-- every species at colours shaded for art it does not use. Bulbasaur wore
+-- PAL_BULBASAUR's red-orange (255,82,49) across 231 pixels of a sprite that
+-- has no red on it at all, and Squirtle wore PAL_SQUIRTLE's shell brown on
+-- his head instead of blue. It looked least wrong on mons whose two mid
+-- tones are close in hue, which is why it survived so long.
+--
+-- The palette VALUES are untouched -- ADVANCED keeps pokered-gbc's richer
+-- colours. Only the species -> name mapping changed.
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.modkit")
+local pack = require("data.palettes_gbc")
+
+T.check(pack and pack.pokemon, "the ADVANCED pack carries a species map")
+
+local n = 0
+for _ in pairs(pack.pokemon) do n = n + 1 end
+T.eq(n, 151, "every species is mapped")
+
+-- Gen 1's palette set: the ten MonsterPalettes names plus nothing else. A
+-- per-species name here means the GEN_2_GRAPHICS table crept back in.
+local GEN1_NAMES = {
+ MEWMON = true, BLUEMON = true, REDMON = true, CYANMON = true,
+ PURPLEMON = true, BROWNMON = true, GREENMON = true, PINKMON = true,
+ YELLOWMON = true, GRAYMON = true,
+}
+
+local strays = {}
+for species, name in pairs(pack.pokemon) do
+ if not GEN1_NAMES[name] then strays[#strays + 1] = species .. "->" .. name end
+end
+table.sort(strays)
+T.eq(#strays, 0,
+ "no species points at a Gen-2-only per-species palette (" ..
+ table.concat(strays, ", ", 1, math.min(#strays, 6)) .. ")")
+
+-- the four the bug was reported on, plus their lines
+local EXPECT = {
+ BULBASAUR = "GREENMON", IVYSAUR = "GREENMON", VENUSAUR = "GREENMON",
+ CHARMANDER = "REDMON", CHARMELEON = "REDMON", CHARIZARD = "REDMON",
+ SQUIRTLE = "CYANMON", WARTORTLE = "CYANMON", BLASTOISE = "CYANMON",
+ MEW = "MEWMON", MEWTWO = "MEWMON", JYNX = "MEWMON",
+ LAPRAS = "CYANMON",
+}
+for species, want in pairs(EXPECT) do
+ T.eq(pack.pokemon[species], want, species .. " uses " .. want)
+end
+
+-- Bulbasaur's palette must contain no red channel dominance in either mid --
+-- the concrete symptom that was reported ("he shouldn't have red anywhere").
+local green = pack.palettes[pack.pokemon.BULBASAUR]
+T.check(green ~= nil, "GREENMON resolves to colours")
+for _, i in ipairs({ 2, 3 }) do
+ local c = green[i]
+ T.check(c[2] > c[1], ("GREENMON mid %d is green-dominant, not red (%d,%d,%d)")
+ :format(i - 1, c[1], c[2], c[3]))
+end
+
+-- and Squirtle's mids must be blue-dominant
+local cyan = pack.palettes[pack.pokemon.SQUIRTLE]
+for _, i in ipairs({ 2, 3 }) do
+ local c = cyan[i]
+ T.check(c[3] >= c[1], ("CYANMON mid %d is blue-dominant (%d,%d,%d)")
+ :format(i - 1, c[1], c[2], c[3]))
+end
+
+T.finish("advanced palette map")
diff --git a/tests/engine/ask_yesno_overlap.lua b/tests/engine/ask_yesno_overlap.lua
new file mode 100644
index 00000000..0b641072
--- /dev/null
+++ b/tests/engine/ask_yesno_overlap.lua
@@ -0,0 +1,82 @@
+-- The script DSL's `ask` command (src/script/Commands.lua) has to bring its
+-- YES/NO box up WHILE the question text is still on screen, the same as
+-- every hand-written prompt
+-- (OverworldController's healer, BattleState's nickname ask, OakSpeech,
+-- MoveLearnMenu -- all TextBox opts.choice). DisplayTextID never clears
+-- the box itself before a YES/NO row; ManualTextScroll's button wait is
+-- what `ask` used to run through, closing the text box first and popping a
+-- bare ChoiceBox in afterwards. Commands.ask now rides opts.choice like
+-- the rest of the engine instead of chaining show_text + a second push.
+-- luajit tests/engine/ask_yesno_overlap.lua
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.harness")
+local check, eq = T.check, T.eq
+love = love or require("tests.love_stub")
+
+local Commands = require("src.script.Commands")
+local TextBox = require("src.render.TextBox")
+local Timing = require("src.core.Timing")
+
+local function newGame()
+ local game = { save = { player = {} }, data = { text = {} } }
+ game.stack = {
+ states = {},
+ push = function(self, s) table.insert(self.states, s) end,
+ pop = function(self) return table.remove(self.states) end,
+ top = function(self) return self.states[#self.states] end,
+ }
+ -- data = {} downstream (Sound.play(game.data, ...)) keeps ChoiceBox's
+ -- un-guarded beep on the headless no-audio path, same as
+ -- rebind_swap_clear_bug589.lua's fixture.
+ game.input = {
+ queue = {},
+ wasPressed = function(self, btn) return self.queue[btn] or false end,
+ isDown = function() return false end,
+ }
+ return game
+end
+
+local game = newGame()
+game.data.text.TEST_ASK = "Shall we heal\nyour POKEMON?"
+
+local resumeCount = 0
+local runner = { yield = function() end,
+ resume = function() resumeCount = resumeCount + 1 end }
+local ctx = { game = game, runner = runner }
+
+Commands.ask(ctx, "TEST_ASK")
+
+eq(#game.stack.states, 1, "ask opens the question as one text box, not a text box plus a bare choice box up front")
+local box = game.stack:top()
+check(getmetatable(box) == TextBox, "the state on the stack is the text box")
+
+-- type the question out
+for _ = 1, 600 do
+ if box.done then break end
+ box:update(1 / 60)
+end
+check(box.done, "the question finished typing")
+eq(#game.stack.states, 1, "the text box does not pop itself on its own once typing is done (#589-parity)")
+
+-- the very next update is what used to require an A press to clear the box
+-- first; now it pushes the YES/NO box straight over the still-open text
+box:update(1 / 60)
+eq(#game.stack.states, 2, "a YES/NO box appears while the question text is still up")
+check(game.stack.states[1] == box, "the text box is still there, underneath")
+local choiceBox = game.stack:top()
+check(choiceBox ~= box, "the choice box is a separate state on top of it")
+
+-- answer YES (the default cursor position) and let the 15-frame hold run
+-- out, same hold rebind_swap_clear_bug589.lua's settleChoice() drains
+game.input.queue = { a = true }
+choiceBox:update(1 / 60)
+game.input.queue = {}
+for _ = 1, Timing.YES_NO_ANSWER do choiceBox:update(1 / 60) end
+
+eq(#game.stack.states, 0, "answering pops both the choice box and the text box")
+eq(ctx.lastCheck, true, "YES lands in ctx.lastCheck")
+eq(resumeCount, 1, "the script runner resumes exactly once")
+
+T.finish("ask_yesno_overlap")
diff --git a/tests/engine/battle_fit_option.lua b/tests/engine/battle_fit_option.lua
new file mode 100644
index 00000000..d0918446
--- /dev/null
+++ b/tests/engine/battle_fit_option.lua
@@ -0,0 +1,81 @@
+-- BATTLE SIZE (save.options.battleFit): "fixed" keeps the classic
+-- integer-scaled letterbox, "fill" scales the battle surface to the window so
+-- it fills vertically.
+--
+-- The bit that regresses is not the arithmetic, it is WHERE the flag is read
+-- from: a battle opens party menus, the bag and text boxes on top of itself,
+-- so reading it off the TOP state would snap the surface back to the fixed
+-- scale for as long as one of those is up. It is read off the whole stack,
+-- the same way the wide-battle layout is.
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.modkit")
+local Game = require("src.core.Game")
+local BattleState = require("src.battle.BattleState")
+
+local function battleWith(fit)
+ return setmetatable({ game = { save = { options = { battleFit = fit } } } },
+ { __index = BattleState })
+end
+
+T.eq(battleWith("fill"):wantsFillScale(), true, "fill asks for the fill scale")
+T.eq(battleWith("fixed"):wantsFillScale(), false, "fixed does not")
+T.eq(battleWith(nil):wantsFillScale(), false, "and neither does an old save")
+
+-- a battle with no game/options at all must not throw
+T.eq(setmetatable({}, { __index = BattleState }):wantsFillScale(), false,
+ "a battle with no options is fixed")
+
+-- the stack scan
+local function stack(...) return { states = { ... } } end
+local overworld = {}
+local fillBattle = battleWith("fill")
+local fixedBattle = battleWith("fixed")
+local partyMenu = {} -- no wantsFillScale at all, like every non-battle state
+
+T.eq(Game.fillScaleInStack(stack(overworld)), false,
+ "no battle in the stack means no fill")
+T.eq(Game.fillScaleInStack(stack(overworld, fixedBattle)), false,
+ "a fixed battle does not fill")
+T.eq(Game.fillScaleInStack(stack(overworld, fillBattle)), true,
+ "a fill battle does")
+T.eq(Game.fillScaleInStack(stack(overworld, fillBattle, partyMenu)), true,
+ "and keeps filling while a party menu sits on top of it")
+T.eq(Game.fillScaleInStack(stack()), false, "an empty stack is safe")
+T.eq(Game.fillScaleInStack(nil), false, "and so is no stack at all")
+
+-- ---------------------------------------------------------------- BATTLE BG
+
+-- What fills the voids AROUND the battle. The battle screen itself keeps its
+-- white field in every mode -- only the surround changes.
+local function battleBg(bg)
+ return setmetatable({ game = { save = { options = { battleBg = bg } } } },
+ { __index = BattleState })
+end
+
+T.eq(battleBg("white"):bgMode(), "white", "white is white")
+T.eq(battleBg("black"):bgMode(), "black", "black is black")
+T.eq(battleBg("world"):bgMode(), "world", "world is world")
+T.eq(battleBg(nil):bgMode(), "white", "an old save defaults to white")
+T.eq(battleBg("nonsense"):bgMode(), "white", "and so does a bad value")
+T.eq(setmetatable({}, { __index = BattleState }):bgMode(), "white",
+ "a battle with no options is white")
+
+-- "world" is the only mode that drops opacity, because it is the only one
+-- that needs the overworld to keep drawing underneath.
+T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("world"))),
+ BattleState.BG_WORLD_DIM, "a world-bg battle asks for its dim")
+T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("white"))), nil,
+ "a white-bg battle asks for none")
+T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("black"))), nil,
+ "and neither does black")
+T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("world"), partyMenu)),
+ BattleState.BG_WORLD_DIM,
+ "the dim survives a party menu opened over the battle")
+T.eq(Game.worldBgBattleDim(stack(overworld)), nil, "no battle, no dim")
+T.eq(Game.worldBgBattleDim(nil), nil, "and no stack is safe")
+
+T.check(BattleState.BG_WORLD_DIM > 0 and BattleState.BG_WORLD_DIM < 1,
+ "the dim is a fraction, not a full blackout")
+
+T.finish("battle fit option")
diff --git a/tests/engine/battle_fixed_menu_scale.lua b/tests/engine/battle_fixed_menu_scale.lua
new file mode 100644
index 00000000..fd5e27d2
--- /dev/null
+++ b/tests/engine/battle_fixed_menu_scale.lua
@@ -0,0 +1,178 @@
+-- BATTLE SIZE "fixed" + BATTLE BG "world": the battle draws as a fixed
+-- letterbox over the map, stepped down with the survey zoom. The PKMN and
+-- ITEM menus it opens have to stay that size, and its YES/NO prompt has to
+-- stay inside it.
+--
+-- Both broke the same way -- by reading a fact about THIS FRAME instead of
+-- about the battle:
+--
+-- * Renderer:uiScale follows the zoom only while a world is behind the UI,
+-- gated on worldActive (beginWorldPass set it this frame). A "world"-bg
+-- battle is non-opaque so the map keeps drawing under it -- but PartyMenu
+-- and ListMenu ARE opaque, so pushing one makes StateStack:visibleBase
+-- skip the map, the world pass never runs, and the menu loses the
+-- step-down and blits a whole integer scale larger than the battle it
+-- just covered. ("fill" hid this: it overrides the scale outright.)
+--
+-- * ChoiceBox bottom-anchored unconditionally, which docks it to the
+-- WINDOW's bottom edge. That is right only when it is riding the
+-- dialogue box below it, which is anchored there too. The battle draws
+-- its own text inside the battle canvas, so the switch offer's YES/NO was
+-- the only piece of that prompt flung to the window edge -- further off
+-- the smaller the fixed battle is drawn.
+-- luajit tests/engine/battle_fixed_menu_scale.lua
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.modkit")
+local Renderer = require("src.render.Renderer")
+local Zoom = require("src.render.Zoom")
+local TextBox = require("src.render.TextBox")
+local ChoiceBox = require("src.ui.ChoiceBox")
+
+-- ------------------------------------------------- the UI scale holds its size
+
+-- pin the window so the scales below are exact numbers rather than whatever
+-- the runner happens to be sized at (this suite also runs under real LOVE)
+local g = love.graphics
+local realDims, realPixelDims = g.getDimensions, g.getPixelDimensions
+g.getDimensions = function() return 640, 576 end
+g.getPixelDimensions = function() return 640, 576 end
+
+-- 640x576 fits the 160x144 classic surface at exactly 4x
+T.eq(Renderer:fitScale(), 4, "the fixture window fits the classic surface at 4x")
+
+local function uiScaleWith(offset, worldActive, hold)
+ local oldOffset, oldActive, oldHold =
+ Zoom.offset, Renderer.worldActive, Renderer.uiWorldHold
+ Zoom.offset, Renderer.worldActive, Renderer.uiWorldHold =
+ offset, worldActive, hold
+ local s = Renderer:uiScale()
+ Zoom.offset, Renderer.worldActive, Renderer.uiWorldHold =
+ oldOffset, oldActive, oldHold
+ return s
+end
+
+T.eq(uiScaleWith(0, true, false), 4, "unzoomed, the UI is the fit scale")
+T.eq(uiScaleWith(-2, true, false), 2,
+ "zoomed out over a live world pass, the UI steps down with it")
+T.eq(uiScaleWith(-2, false, false), 4,
+ "with no world behind it at all (title screen), the zoom is ignored")
+
+-- the fix: the party menu / bag ended the world pass, but the battle under
+-- them is still drawn over the map, so the surface must not grow
+T.eq(uiScaleWith(-2, false, true), 2,
+ "an opaque menu over a world-bg battle keeps the battle's stepped-down scale")
+T.eq(uiScaleWith(0, false, true), 4,
+ "and the hold changes nothing when the player never zoomed out")
+
+g.getDimensions, g.getPixelDimensions = realDims, realPixelDims
+
+-- the hold is the same whole-stack answer the dim already uses, so a menu
+-- opened over the battle cannot drop it for a frame (Game:draw wires
+-- uiWorldHold to worldBgBattleDim ~= nil; battle_fit_option covers the scan)
+local Game = require("src.core.Game")
+local BattleState = require("src.battle.BattleState")
+-- isOpaque false is what "world" actually does to a live battle (BattleState
+-- drops it per-instance at start, so the class default stays opaque for every
+-- other battle) -- and it is the whole reason the map draws underneath
+local worldBattle = setmetatable(
+ { game = { save = { options = { battleBg = "world" } } }, isOpaque = false },
+ { __index = BattleState })
+T.check(Game.worldBgBattleDim({ states = { {}, worldBattle, {} } }) ~= nil,
+ "the stack scan the hold reads still finds the battle under an opaque menu")
+
+-- --------------------------------------------- the backdrop holds under menus
+
+-- The battle establishes the surround too, and the same opaque menu used to
+-- take that over: it becomes visibleBase, the overworld stops drawing, and
+-- the world the battle was composed over collapses to a flat black clear.
+local overworld = { isOpaque = true }
+local menu = { isOpaque = true } -- PartyMenu / ListMenu
+local whiteBattle = setmetatable(
+ { game = { save = { options = { battleBg = "white" } } } },
+ { __index = BattleState })
+local function stack(...) return { states = { ... },
+ visibleBase = function(self)
+ for i = #self.states, 1, -1 do
+ if self.states[i].isOpaque then return i end
+ end
+ return 1
+ end } end
+
+local s = stack(overworld, worldBattle, menu)
+T.eq(s:visibleBase(), 3, "the menu is the topmost opaque state, as before")
+T.eq(Game.drawBaseInStack(s, s:visibleBase()), 1,
+ "but the frame still starts at the overworld, so the map keeps drawing")
+
+-- unchanged everywhere else
+local s2 = stack(overworld, worldBattle)
+T.eq(s2:visibleBase(), 1, "the battle alone already drew from the overworld")
+T.eq(Game.drawBaseInStack(s2, s2:visibleBase()), 1, "and still does")
+local s3 = stack(overworld, whiteBattle, menu)
+T.eq(Game.drawBaseInStack(s3, s3:visibleBase()), 3,
+ "a white-bg battle has no map to hold, so nothing moves")
+local s4 = stack(overworld, menu)
+T.eq(Game.drawBaseInStack(s4, s4:visibleBase()), 2,
+ "and a menu outside a battle is untouched")
+T.eq(Game.drawBaseInStack(stack(overworld), 1), 1, "a lone base is safe")
+T.eq(Game.drawBaseInStack(nil, 1), 1, "and so is no stack at all")
+
+-- ------------------------------------------- the YES/NO stays with its screen
+
+-- a bare choice box -- the battle's switch offer, a shop or PC confirm -- has
+-- no anchored dialogue box under it to ride
+T.eq(ChoiceBox.new({}, function() end).anchor, nil,
+ "a bare choice box does not anchor itself to the window edge")
+T.eq(ChoiceBox.new({}, function() end, { defaultNo = true }).anchor, nil,
+ "and neither does one that only asked to start on NO")
+
+-- ...but the one a dialogue box opens does, because that box is anchored too
+local game = { save = { player = {} }, data = { text = {} } }
+game.stack = {
+ states = {},
+ push = function(self, s) table.insert(self.states, s) end,
+ pop = function(self) return table.remove(self.states) end,
+ top = function(self) return self.states[#self.states] end,
+}
+game.input = {
+ wasPressed = function() return false end,
+ isDown = function() return false end,
+}
+
+local box = TextBox.new(game, "Shall we heal\nyour POKEMON?", nil,
+ { choice = function() end })
+game.stack:push(box)
+for _ = 1, 600 do
+ if box.done then break end
+ box:update(1 / 60)
+end
+T.check(box.done, "the question finished typing")
+box:update(1 / 60) -- the update after done is the one that pushes the choice
+T.eq(#game.stack.states, 2, "the dialogue box opened its YES/NO")
+T.eq(game.stack:top().anchor, "bottom",
+ "a choice box riding a dialogue box shares its bottom anchor")
+
+-- ...and inside a battle even THAT one stays put, because the battle is the
+-- screen: the caught-mon nickname prompt prints on the blanked battle field
+-- (BattleState.blankForAskName), and docking it to the window edge drops it a
+-- whole letterbox below what it is printed on.
+T.eq(BattleState.holdsUIAnchors, true, "a battle composes its own screen")
+T.eq(Game.uiAnchorsHeldInStack(stack(overworld, worldBattle)), true,
+ "so the anchors are held while it is up")
+T.eq(Game.uiAnchorsHeldInStack(stack(overworld, worldBattle, {})), true,
+ "including for the text box and YES/NO it opens above itself")
+T.eq(Game.uiAnchorsHeldInStack(stack(overworld)), false,
+ "the overworld's own dialogue box still docks to the screen edge")
+T.eq(Game.uiAnchorsHeldInStack(nil), false, "and no stack is safe")
+
+Renderer.uiAnchors = nil
+Renderer.uiAnchorHold = true
+Renderer:setUIAnchor(0, 96, 160, 48, "bottom")
+T.eq(Renderer.uiAnchors, nil, "a held anchor never reaches the frame")
+Renderer.uiAnchorHold = false
+Renderer:setUIAnchor(0, 96, 160, 48, "bottom")
+T.eq(#(Renderer.uiAnchors or {}), 1, "and an unheld one still does")
+Renderer.uiAnchors = nil
+
+T.finish("battle fixed menu scale")
diff --git a/tests/engine/effect_stereo_bug626.lua b/tests/engine/effect_stereo_bug626.lua
new file mode 100644
index 00000000..6c2d9e21
--- /dev/null
+++ b/tests/engine/effect_stereo_bug626.lua
@@ -0,0 +1,86 @@
+-- Every one-shot the engine synthesizes must land in a two-channel buffer
+-- (#626). OpenAL only spatializes 1-channel Sources, and nothing in this
+-- repo ever calls setPosition/setRelative, so a mono effect Source sat at the
+-- default (0,0,0) -- on top of the listener -- which OpenAL Soft renders as
+-- an ambient sound spread over EVERY output channel the device exposes. On a
+-- 6-channel interface the SFX therefore also came out of outputs 5+6 at gains
+-- that differ from the front pair, while the music stayed put because
+-- ChipAudio.playMusic is a 2-channel queueable source. Multi-channel buffers
+-- skip spatialization outright, so the channel count is the invariant to
+-- guard; the routing itself only an ear on a multi-output interface can
+-- settle (tests/drivers).
+--
+-- The duplication is also the faithful default: pokered's stereo panning byte
+-- (audio/engine_1.asm Audio1_stereo_panning -> wStereoPanning -> rAUDTERM)
+-- is 0xFF, both sides, for everything that never issues command 0xEE.
+-- ROM-free: ChipAsm blobs, no data/generated/.
+-- luajit tests/engine/effect_stereo_bug626.lua
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.harness")
+local check = T.check
+
+love = require("tests.love_stub")
+
+local ChipAsm = require("src.audio.ChipAsm")
+local ChipSynth = require("src.core.ChipSynth")
+local ChipAudio = require("src.core.ChipAudio")
+
+-- a short blob effect: one audible note on a pulse channel, no loops (the
+-- effect renderer forces allowLoops=false anyway)
+local beep = ChipAsm.song{
+ tempo = 0x100,
+ channels = { { hw = 1, program = {
+ { duty = 2 },
+ { notetype = { speed = 12, volume = 12, fade = 0 } },
+ { octave = 4 },
+ { note = "C", len = 12 },
+ { note = "E", len = 12 },
+ } } },
+}
+local blobData = { audio = { sfx = {}, cries = {} } }
+
+local sd = ChipSynth.renderEffectData(blobData, beep, {})
+check(sd ~= nil, "the blob effect renders at all")
+check(sd:getChannelCount() == 2,
+ ("an effect buffer is stereo, not %d channel(s)")
+ :format(sd:getChannelCount()))
+
+-- both channels must carry the identical sample: the synthesis stays mono
+-- (Engine:sample, not the music path's Engine:sampleStereo), so the only
+-- thing that changed for a listener is which outputs the sound reaches
+local frames = sd:getSampleCount()
+local mismatched, nonzero = 0, 0
+for index = 0, frames - 1 do
+ local left, right = sd:getSample(index, 1), sd:getSample(index, 2)
+ if left ~= right then mismatched = mismatched + 1 end
+ if left ~= 0 then nonzero = nonzero + 1 end
+end
+check(nonzero > 0, "the effect buffer is not silent")
+check(mismatched == 0,
+ ("both channels carry the same sample (%d frames differ)")
+ :format(mismatched))
+
+-- The low-health siren is hand-synthesized rather than going through the
+-- effect renderer, so it needs its own guard (ChipAudio.newLowHealthAlarm).
+-- tests/love_stub carries no love.audio (headless suites never play), so the
+-- buffer is caught on its way into newSource.
+local captured
+love.audio = { newSource = function(sd) captured = sd; return { sd } end }
+local alarmOk, alarmErr = pcall(ChipAudio.newLowHealthAlarm)
+love.audio = nil
+check(alarmOk, ("the low-health siren builds: %s"):format(tostring(alarmErr)))
+check(captured ~= nil and captured:getChannelCount() == 2,
+ "the low-health siren is stereo")
+if captured then
+ local siren = 0
+ for index = 0, captured:getSampleCount() - 1 do
+ if captured:getSample(index, 1) ~= captured:getSample(index, 2) then
+ siren = siren + 1
+ end
+ end
+ check(siren == 0, "both siren channels carry the same sample")
+end
+
+T.finish("effect stereo (#626)")
diff --git a/tests/engine/faithful_res.lua b/tests/engine/faithful_res.lua
new file mode 100644
index 00000000..0f17b044
--- /dev/null
+++ b/tests/engine/faithful_res.lua
@@ -0,0 +1,146 @@
+-- FAITHFUL RATIO: lock the window to an exact 160x144 multiple so the surface
+-- is the Game Boy screen with no letterbox at all.
+--
+-- The interesting parts are the two things a naive setMode gets wrong: the
+-- window minimum from conf.lua (480x360) sits ABOVE 1X and 2X, so the lock
+-- has to lower it or LOVE clamps the window straight back up; and on a HiDPI
+-- display a LOVE unit is more than a pixel, so asking for the pixel count
+-- directly gives a window twice the size intended.
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.modkit")
+local FaithfulRes = require("src.core.FaithfulRes")
+
+-- ---------------------------------------------------------------- values
+
+T.eq(FaithfulRes.normalize(nil), 0, "no setting is OFF")
+T.eq(FaithfulRes.normalize("junk"), 0, "garbage degrades to OFF")
+T.eq(FaithfulRes.normalize(-3), 0, "negatives clamp to OFF")
+T.eq(FaithfulRes.normalize(9), 4, "above 4X clamps to 4X")
+T.eq(FaithfulRes.normalize(2.7), 2, "fractions floor to a whole multiple")
+
+T.eq(FaithfulRes.label(0), "OFF", "0 reads OFF")
+T.eq(FaithfulRes.label(1), "1X", "1 reads 1X")
+T.eq(FaithfulRes.label(4), "4X", "4 reads 4X")
+
+-- the row cycles OFF -> 1X -> 2X -> 3X -> 4X -> OFF
+local seen, v = {}, 0
+for _ = 1, 5 do
+ seen[#seen + 1] = FaithfulRes.label(v)
+ v = FaithfulRes.cycle(v, 1)
+end
+T.eq(table.concat(seen, ","), "OFF,1X,2X,3X,4X", "the row cycles through OFF..4X")
+T.eq(FaithfulRes.cycle(4, 1), 0, "and wraps back to OFF")
+T.eq(FaithfulRes.cycle(0, -1), 4, "stepping back from OFF lands on 4X")
+
+-- ---------------------------------------------------------------- sizing
+
+local savedWindow = love.window
+local savedSystem = love.system
+local savedDims = love.graphics and love.graphics.getDimensions
+local savedPixels = love.graphics and love.graphics.getPixelDimensions
+
+-- `ratio` is PHYSICAL PIXELS PER UNIT, which is what the window reports and
+-- what FaithfulRes measures -- not love.window.getDPIScale, which lies about
+-- a window that is not high-DPI aware (that was the bug).
+local function stubWindow(ratio)
+ local calls = {}
+ love.graphics = love.graphics or {}
+ love.graphics.getDimensions = function() return 1024, 768 end
+ love.graphics.getPixelDimensions = function()
+ return 1024 * ratio, 768 * ratio
+ end
+ love.window = {
+ -- deliberately WRONG, and deliberately present: nothing may read it
+ getDPIScale = function() return 999 end,
+ getMode = function()
+ return 1024, 768, { fullscreen = true, resizable = true,
+ minwidth = 480, minheight = 360, vsync = 1 }
+ end,
+ setMode = function(w, h, flags)
+ calls[#calls + 1] = { w = w, h = h, flags = flags }
+ end,
+ }
+ return calls
+end
+
+-- A plain desktop window reports units == pixels, whatever the DISPLAY
+-- scaling is. Dividing by the display scale here is what made 2X render at
+-- 1X and 4X at 3X, so the stub returns a nonsense getDPIScale to prove
+-- nothing consults it.
+stubWindow(1)
+local w, h = FaithfulRes.size(2)
+T.eq(w, 320, "2X is 320 units wide when a unit is a pixel")
+T.eq(h, 288, "2X is 288 units tall when a unit is a pixel")
+T.eq(FaithfulRes.size(0), nil, "OFF has no size")
+
+local w4, h4 = FaithfulRes.size(4)
+T.eq(w4, 640, "4X is 640 wide, not shrunk by the display scale")
+T.eq(h4, 576, "4X is 576 tall")
+
+-- a genuinely high-DPI window reports more pixels than units, so the unit
+-- size halves to keep the PHYSICAL pixel count exact
+stubWindow(2)
+local w2, h2 = FaithfulRes.size(2)
+T.eq(w2, 160, "2X asks for 160 units at 2 px/unit, which is 320 pixels")
+T.eq(h2, 144, "and 144 units, which is 288 pixels")
+
+-- ---------------------------------------------------------------- applying
+
+FaithfulRes.locked = false
+local calls = stubWindow(1)
+love.system = { getOS = function() return "Windows" end }
+
+-- OFF on boot must not touch a window the player sized themselves
+T.eq(FaithfulRes.apply(0), false, "OFF reports unlocked")
+T.eq(#calls, 0, "and does not resize an unlocked window on boot")
+
+T.eq(FaithfulRes.apply(3), true, "3X reports locked")
+T.eq(#calls, 1, "which took one setMode")
+T.eq(calls[1].w, 480, "3X is 480 wide")
+T.eq(calls[1].h, 432, "3X is 432 tall")
+T.eq(calls[1].flags.fullscreen, false,
+ "an exact size drops fullscreen -- the two cannot both hold")
+T.eq(calls[1].flags.resizable, false,
+ "and fixes the window, since a drag would silently break the lock")
+
+-- 1X and 2X are below conf.lua's 480x360 floor; without lowering it LOVE
+-- clamps the window back up and the lock silently does nothing
+calls = stubWindow(1)
+FaithfulRes.apply(1)
+T.eq(calls[1].w, 160, "1X is 160 wide")
+T.eq(calls[1].flags.minwidth, 160, "and lowers the window minimum to match")
+T.eq(calls[1].flags.minheight, 144, "on both axes")
+
+-- releasing the lock restores the resizable window and its original floor
+calls = stubWindow(1)
+T.eq(FaithfulRes.apply(0), false, "OFF reports unlocked")
+T.eq(#calls, 1, "and this time it does resize, because we held the window")
+T.eq(calls[1].flags.resizable, true, "handing resizing back to the player")
+T.eq(calls[1].flags.minwidth, FaithfulRes.MIN_W, "with conf.lua's floor restored")
+T.eq(calls[1].flags.minheight, FaithfulRes.MIN_H, "on both axes")
+T.eq(FaithfulRes.locked, false, "and the module no longer claims the window")
+
+-- Mobile has no resizable window to lock, so it locks the RENDER scale
+-- instead and never calls setMode. It used to report unlocked and do
+-- nothing at all, which is why the OPTIONS row was inert on Android and iOS;
+-- tests/engine/faithful_res_mobile.lua covers the scale side.
+calls = stubWindow(1)
+love.system = { getOS = function() return "Android" end }
+T.eq(FaithfulRes.apply(4), true, "mobile locks, by capping the render scale")
+-- the level asked for is irrelevant on mobile: ON is ON, and the scale is
+-- read off the display so the picture is as big as exact pixels allow
+T.eq(FaithfulRes.scaleCap(), FaithfulRes.deviceScale(),
+ "and the scale comes from the display, not from the level")
+T.eq(#calls, 0, "still without ever touching the window")
+T.eq(FaithfulRes.apply(0), false, "OFF releases it")
+T.eq(FaithfulRes.scaleCap(), nil, "and the cap goes away with it")
+T.eq(#calls, 0, "the window is left alone either way")
+FaithfulRes.mobileScale = 0
+
+love.window, love.system = savedWindow, savedSystem
+if love.graphics then
+ love.graphics.getDimensions = savedDims
+ love.graphics.getPixelDimensions = savedPixels
+end
+T.finish("faithful resolution")
diff --git a/tests/engine/faithful_res_mobile.lua b/tests/engine/faithful_res_mobile.lua
new file mode 100644
index 00000000..9457e5a5
--- /dev/null
+++ b/tests/engine/faithful_res_mobile.lua
@@ -0,0 +1,134 @@
+-- FAITHFUL RATIO on Android / iOS.
+--
+-- On mobile the setting is ON or OFF, and ON means one thing: lock the
+-- viewport to the Game Boy's 10:9 at the largest WHOLE multiple this screen
+-- can hold, centred, black around it -- the way an emulator opens a Game Boy
+-- game on a phone.
+--
+-- Three things had to be true and none of them were:
+--
+-- * it had to apply at all. FaithfulRes.apply returned false on its first
+-- line for mobile, so the OPTIONS row did nothing on Android and iOS.
+-- A phone has no window to resize, so the lock caps the RENDER scale.
+--
+-- * it had to be sized for the device. The first cut kept the desktop's
+-- absolute 1X-4X ladder, which names a window size you can see on a
+-- desktop and means a different fraction of every phone: 4X was a quarter
+-- of a 1080p display and 5X/6X were not on the list at all. The scale is
+-- read off the display now, not chosen by the player.
+--
+-- * it had to work in the OVERWORLD. The world pass deliberately expands
+-- to cover the whole display so letterbox voids become more map. So the
+-- lock shrank the UI blit while the map kept filling the screen -- and
+-- showed MORE map, since a smaller scale fits more world pixels in.
+--
+-- Pixel perfect throughout: whole multiples only. The leftover is bars, and
+-- on a 9:20 phone there is a lot of it vertically. That is what a 10:9
+-- screen looks like on a tall display; stretching to reach the edges would
+-- resample every pixel, which is the one thing this setting exists to refuse.
+-- luajit tests/engine/faithful_res_mobile.lua
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.modkit")
+local FaithfulRes = require("src.core.FaithfulRes")
+local Renderer = require("src.render.Renderer")
+local Zoom = require("src.render.Zoom")
+
+local g = love.graphics
+local realDims, realPixelDims = g.getDimensions, g.getPixelDimensions
+local realOS = love.system and love.system.getOS
+local savedOffset = Zoom.offset
+love.system = love.system or {}
+
+local function pose(w, h, osName)
+ love.system.getOS = function() return osName or "Android" end
+ g.getDimensions = function() return w, h end
+ g.getPixelDimensions = function() return w, h end
+end
+
+Renderer.uiWidth, Renderer.uiHeight = Renderer.WIDTH, Renderer.HEIGHT
+Zoom.offset = 0
+
+-- ------------------------------------------------------- it applies at all
+
+pose(1080, 2400) -- a Pixel 7, portrait
+T.eq(FaithfulRes.isMobile(), true, "the fixture reads as a phone")
+T.eq(FaithfulRes.apply(1), true, "FAITHFUL RATIO applies on mobile at all now")
+T.eq(FaithfulRes.locked, true, "and reports itself locked")
+
+-- ------------------------------------------------- one ON, sized by the device
+
+T.eq(FaithfulRes.maxLevel(), 1, "mobile offers ON, not a ladder of multiples")
+T.eq(#FaithfulRes.levels(), 2, "so the row is exactly OFF and ON")
+T.eq(FaithfulRes.label(1), "ON", "and ON is spelled ON, not 1X")
+T.eq(FaithfulRes.label(0), "OFF", "with OFF unchanged")
+
+-- 1080/160 = 6.75 and 2400/144 = 16.6, so the largest WHOLE multiple is 6
+T.eq(FaithfulRes.deviceScale(), 6, "the scale comes off the display: 6x here")
+T.eq(FaithfulRes.scaleCap(), 6, "and that is what the renderer is told to lock")
+T.eq(Renderer:fitScale(), 6, "so a GB pixel is exactly 6 screen pixels")
+
+-- the player never picks a smaller one, which is how the first cut managed to
+-- draw a postage stamp on a 1080p phone
+T.eq(FaithfulRes.normalize(3), 1, "any ON value is just ON")
+FaithfulRes.apply(1)
+T.eq(Renderer:fitScale(), 6, "and always lands on the device maximum")
+
+-- --------------------------------------------------------- the overworld
+
+FaithfulRes.apply(0)
+local unlockedW = Renderer:worldViewSize()
+T.check(unlockedW > 160, "unlocked, the overworld view covers the whole display")
+
+FaithfulRes.apply(1)
+local lockedW, lockedH = Renderer:worldViewSize()
+T.eq(lockedW, 160, "locked, the overworld shows exactly a GB screen wide")
+T.eq(lockedH, 144, "and exactly a GB screen tall")
+T.check(lockedW < unlockedW,
+ "so the lock SHRINKS the map area instead of growing it")
+
+-- ------------------------------------------------------ pixel perfect
+
+-- 6 x 160 = 960 of 1080 wide. Reaching the edges would need 6.75, which
+-- resamples every pixel; the bars are the honest answer.
+local cap = FaithfulRes.scaleCap()
+T.eq(cap, math.floor(cap), "the locked scale is a whole number, never fractional")
+T.eq(160 * cap, 960, "which puts the GB screen at 960 of 1080 pixels wide")
+
+-- ------------------------------------------------------- rotation is free
+
+-- fitScale reads the live drawable size every frame, so a rotate re-derives
+-- the scale with nothing to re-apply
+pose(1080, 2400); FaithfulRes.apply(1)
+T.eq(Renderer:fitScale(), 6, "portrait locks at 6x")
+pose(2400, 1080); FaithfulRes.apply(1)
+T.eq(Renderer:fitScale(), 7, "landscape re-derives to 7x (1080/144), still whole")
+T.eq(Renderer:worldViewSize(), 160, "and the overworld stays locked through it")
+
+-- ---------------------------------------------------------- OFF is OFF
+
+-- Nothing about OFF changed: it is the behaviour the game already had.
+pose(1080, 2400)
+FaithfulRes.apply(0)
+T.eq(FaithfulRes.locked, false, "OFF releases the lock")
+T.eq(FaithfulRes.scaleCap(), nil, "with no cap on the renderer")
+T.eq(Renderer:fitScale(), 6, "the UI fits exactly as it did before")
+T.check(Renderer:worldViewSize() > 160, "and the overworld fills the screen again")
+
+-- ------------------------------------------------- desktop is untouched
+
+pose(1280, 800, "Windows")
+T.eq(FaithfulRes.isMobile(), false, "the fixture reads as desktop")
+T.eq(FaithfulRes.maxLevel(), 4, "desktop keeps its 1X-4X ladder")
+T.eq(FaithfulRes.label(2), "2X", "and its labels")
+T.eq(FaithfulRes.scaleCap(), nil, "no cap: the window itself is the lock there")
+T.eq(Renderer:fitScale(), 5, "so fitScale is untouched (800/144 = 5)")
+
+g.getDimensions, g.getPixelDimensions = realDims, realPixelDims
+if realOS then love.system.getOS = realOS end
+Zoom.offset = savedOffset
+FaithfulRes.locked = false
+FaithfulRes.mobileScale = 0
+
+T.finish("faithful ratio mobile")
diff --git a/tests/engine/fixed_damage_immunity_bug616.lua b/tests/engine/fixed_damage_immunity_bug616.lua
new file mode 100644
index 00000000..47456abf
--- /dev/null
+++ b/tests/engine/fixed_damage_immunity_bug616.lua
@@ -0,0 +1,96 @@
+-- Gen 1 fixed-damage moves and Super Fang ignore the type chart (#616).
+-- SPECIAL_DAMAGE_EFFECT and SUPER_FANG_EFFECT are the SetDamageEffects
+-- table (data/battle/set_damage_effects.asm); engine/battle/core.asm:3139
+-- jumps straight to MoveHitTest for them, so CalculateDamage and
+-- AdjustDamageForMoveType never run and ApplyAttackToEnemyPokemon
+-- (core.asm:4612) stores wDamage unscaled. Night Shade therefore hits
+-- Normal-types and Super Fang hits Ghosts. OHKO is the exception: it
+-- returns through AdjustDamageForMoveType (core.asm:4329, 4467) and a 0x
+-- matchup still zeroes its 65535 and sets wMoveMissed.
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.modkit")
+local Data = T.fixtures.fresh()
+local Font = require("src.render.Font")
+Font.load(Data)
+local BattleState = require("src.battle.BattleState")
+local Pokemon = require("src.pokemon.Pokemon")
+local SaveData = require("src.core.SaveData")
+-- the shared fixture type chart is just the GRASS/FIRE/WATER triangle
+-- (tests/fixture_data/type_chart.lua) with no 0x row, so this suite adds
+-- its own immune matchups: one for the SetDamageEffects moves under test
+-- and a separate one for OHKO, kept apart so neither probe leaks into
+-- the other's assertion
+table.insert(Data.type_chart.matchups,
+ { attacker = "FIX_ATK_A", defender = "FIX_DEF_A", multiplier = 0 })
+table.insert(Data.type_chart.matchups,
+ { attacker = "FIX_ATK_B", defender = "FIX_DEF_B", multiplier = 0 })
+local TypeChart = require("src.battle.TypeChart")
+TypeChart.load(Data)
+
+-- Night Shade's shape: 0 BP, fixed damage equal to the user's level,
+-- attacking type that the fixture chart marks as 0x against the target
+Data.moves.FIX_NIGHT_SHADE = {
+ id = "FIX_NIGHT_SHADE", index = 98, name = "FIX NSHADE",
+ type = "FIX_ATK_A", power = 0, accuracy = 100, pp = 15,
+ effect = "SPECIAL_DAMAGE_EFFECT", fixedDamage = "level",
+}
+Data.moves.FIX_SUPER_FANG = {
+ id = "FIX_SUPER_FANG", index = 97, name = "FIX SFANG",
+ type = "FIX_ATK_A", power = 1, accuracy = 90, pp = 10,
+ effect = "SUPER_FANG_EFFECT",
+}
+Data.moves.FIX_FISSURE = {
+ id = "FIX_FISSURE", index = 96, name = "FIX FISSURE",
+ type = "FIX_ATK_B", power = 1, accuracy = 30, pp = 5,
+ effect = "OHKO_EFFECT",
+}
+
+local function mkseq(vals) -- scripted rng: pops vals, then max rolls
+ local i = 0
+ return function(_, hi)
+ i = i + 1
+ return vals[i] ~= nil and vals[i] or hi
+ end
+end
+
+-- a battle whose target's live types are forced to `types` (curTypes is
+-- what TypeChart reads, BattleState.lua:443)
+local function mkbattle(level, types)
+ local save = SaveData.newGame()
+ save.party = { Pokemon.new(Data, "FIXMON_A", level) }
+ local game = { data = Data, save = save,
+ stack = { top = function() return nil end, push = function() end } }
+ local b = BattleState.newWild(game, "FIXMON_C", 5)
+ b.enemy.curTypes = types
+ b.rng = mkseq({ 0 }) -- accuracy hits; nothing else rolls
+ return b
+end
+
+-- FIX_ATK_A is 0x against FIX_DEF_A, and Night Shade lands anyway
+T.eq(TypeChart.effectiveness("FIX_ATK_A", { "FIX_DEF_A" }), 0,
+ "FIX_ATK_A does nothing to FIX_DEF_A")
+local ns = mkbattle(12, { "FIX_DEF_A" })
+local before = ns.enemy.mon.hp
+ns:performMove(ns.player, ns.enemy, { id = "FIX_NIGHT_SHADE", pp = 15 })
+T.eq(before - ns.enemy.mon.hp, 12, "Night Shade takes level damage off an immune type")
+
+-- same 0x matchup, and Super Fang still halves
+local sf = mkbattle(12, { "FIX_DEF_A" })
+local hp = sf.enemy.mon.hp
+sf:performMove(sf.player, sf.enemy, { id = "FIX_SUPER_FANG", pp = 10 })
+T.eq(hp - sf.enemy.mon.hp, math.max(1, math.floor(hp / 2)),
+ "Super Fang halves an immune type's HP")
+
+-- OHKO keeps its immunity gate: it does return through AdjustDamageForMoveType
+T.eq(TypeChart.effectiveness("FIX_ATK_B", { "FIX_DEF_B" }), 0,
+ "FIX_ATK_B does nothing to FIX_DEF_B")
+local ko = mkbattle(50, { "FIX_DEF_B" })
+local full = ko.enemy.mon.hp
+ko:performMove(ko.player, ko.enemy, { id = "FIX_FISSURE", pp = 5 })
+T.eq(ko.enemy.mon.hp, full, "OHKO still cannot touch an immune type")
+
+Data.moves.FIX_NIGHT_SHADE = nil
+Data.moves.FIX_SUPER_FANG = nil
+Data.moves.FIX_FISSURE = nil
+T.finish("fixed damage ignores type immunity")
diff --git a/tests/engine/game_display_chord_test.lua b/tests/engine/game_display_chord_test.lua
index a7235f15..31f3802a 100644
--- a/tests/engine/game_display_chord_test.lua
+++ b/tests/engine/game_display_chord_test.lua
@@ -114,9 +114,11 @@ Game:gamepadpressed(joySelectDown, "y")
eq(digits[1], "5", "Select via isGamepadDown(back) + Y -> 5")
eq(#padForwarded, 0, "isGamepadDown Select chord does not forward face")
--- Edge: every chord face without Select must not cycle (NXMOD-09)
+-- Edge: face chords without Select must not cycle digits (NXMOD-09).
+-- leftshoulder without Select is the GAME SPEED hotkey (upstream), so it
+-- does not reach Input — only face buttons do.
GamepadMap._setForceNXForTests(false)
-for _, btn in ipairs({ "a", "b", "y", "x", "leftshoulder" }) do
+for _, btn in ipairs({ "a", "b", "y", "x" }) do
Input:init()
resetSpies()
Game:gamepadpressed(joy, btn)
@@ -125,6 +127,34 @@ for _, btn in ipairs({ "a", "b", "y", "x", "leftshoulder" }) do
check(not wroteOptions, "edge: no options write without Select for " .. btn)
end
+-- leftshoulder alone cycles speed (does not forward / does not digit).
+do
+ local sped = 0
+ local origCycle = Game._cycleSpeed
+ function Game:_cycleSpeed(dir) sped = sped + (dir or 0) end
+ Input:init()
+ resetSpies()
+ Game:gamepadpressed(joy, "leftshoulder")
+ eq(#digits, 0, "edge: L alone does not fire display digit")
+ eq(#padForwarded, 0, "edge: L alone does not forward to Input (speed hotkey)")
+ eq(sped, -1, "edge: L alone cycles GAME SPEED down")
+ Game._cycleSpeed = origCycle
+end
+
+-- Select+L still wins over the speed hotkey.
+holdSelect()
+resetSpies()
+do
+ local sped = 0
+ local origCycle = Game._cycleSpeed
+ function Game:_cycleSpeed(dir) sped = sped + (dir or 0) end
+ Game:gamepadpressed(joy, "leftshoulder")
+ eq(digits[1], "7", "Select+L still fires display digit 7")
+ eq(sped, 0, "Select+L does not cycle GAME SPEED")
+ eq(#padForwarded, 0, "Select+L does not forward L to Input")
+ Game._cycleSpeed = origCycle
+end
+
-- Edge: Select alone (no face) does not synthesize a digit
holdSelect()
resetSpies()
diff --git a/tests/engine/gate_strings_coverage.lua b/tests/engine/gate_strings_coverage.lua
index 18daa4e3..e03402a3 100644
--- a/tests/engine/gate_strings_coverage.lua
+++ b/tests/engine/gate_strings_coverage.lua
@@ -62,10 +62,19 @@ end
-- Blank out every Strings(...) / Strings.source(...) call span, parens
-- balanced, so a call wrapped across lines counts as covered. A per-line
-- test reported the continuation lines of three real calls as misses.
+--
+-- romText(...) counts as a router too: it prefers the line the importer
+-- extracted from the ROM and hands its literal straight to Strings(...)
+-- whenever that label is absent (a cache built before it, or a dataset-less
+-- unit test), so the literal is still catalog-backed and a translation mod
+-- still reaches it. Blanking the whole span is safe -- the only literals
+-- inside are the pokered label and that fallback.
local function stripStringsCalls(body)
local out, i, n = {}, 1, #body
while i <= n do
local s, e = body:find("Strings%.?s?o?u?r?c?e?%(", i)
+ local rs = body:find("romText%(", i)
+ if rs and (not s or rs < s) then s, e = rs, nil end
if not s then out[#out + 1] = body:sub(i) break end
out[#out + 1] = body:sub(i, s - 1)
local depth, j = 0, body:find("%(", s)
diff --git a/tests/engine/host_hide_console_bug606.lua b/tests/engine/host_hide_console_bug606.lua
new file mode 100644
index 00000000..616409c2
--- /dev/null
+++ b/tests/engine/host_hide_console_bug606.lua
@@ -0,0 +1,33 @@
+-- #606: on Windows every shelled-out host tool (curl, the PowerShell ROM
+-- picker, the update downloader) flashed its own cmd.exe console window,
+-- because a GUI-subsystem process has no console for the child to inherit.
+-- HostShell.hideHostConsole claims one hidden console at boot so the children
+-- inherit it silently. The Windows half needs Windows; what this tier can pin
+-- is that the helper is a harmless memoized no-op everywhere else, and that
+-- main.lua actually calls it (an unwired helper fixes nothing).
+-- luajit tests/engine/host_hide_console_bug606.lua
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.harness")
+local check, eq = T.check, T.eq
+love = love or require("tests.love_stub")
+
+local HostShell = require("src.core.HostShell")
+
+check(type(HostShell.hideHostConsole) == "function",
+ "HostShell exposes hideHostConsole (#606)")
+
+local ok, hidden = pcall(HostShell.hideHostConsole)
+check(ok, "hideHostConsole never throws")
+eq(hidden, false, "non-Windows hosts allocate no console")
+eq(select(2, pcall(HostShell.hideHostConsole)), false,
+ "the answer is memoized, so repeat calls stay a no-op")
+
+local f = assert(io.open("main.lua", "r"))
+local src = f:read("*a")
+f:close()
+check(src:find("hideHostConsole", 1, true) ~= nil,
+ "love.load wires the console suppression up at boot (#606)")
+
+T.finish("host_hide_console_bug606")
diff --git a/tests/engine/oaks_lab_dex_rating_bug600.lua b/tests/engine/oaks_lab_dex_rating_bug600.lua
new file mode 100644
index 00000000..f20a152d
--- /dev/null
+++ b/tests/engine/oaks_lab_dex_rating_bug600.lua
@@ -0,0 +1,69 @@
+-- Regression coverage for #600 "Wrong Prof. Oak dialogue" (T2, ROM-free).
+--
+-- pret/pokered scripts/OaksLab.asm OaksLabOak1Text opens with the dex
+-- rating branch: EVENT_PALLET_AFTER_GETTING_POKEBALLS, or 2+ owned species
+-- (CountSetBits over wPokedexOwned) once EVENT_GOT_POKEDEX is set, sends
+-- Oak to .HowIsYourPokedexComingText + predef DisplayDexRating. The port
+-- skipped that branch entirely, so a player with the Pokedex kept getting
+-- .PokemonAroundTheWorldText, the line Oak reads right after handing it
+-- over. Yellow already had the branch (data/scripts/oaks_lab_yellow.lua);
+-- Red additionally gates it on GOT_POKEDEX, which Yellow's copy drops.
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.harness")
+local MapScripts = require("src.script.MapScripts")
+
+local contribution = dofile("data/scripts/oaks_lab.lua")
+T.eq(#MapScripts.validateContribution(contribution), 0,
+ "oaks_lab contribution validates cleanly")
+
+local script = contribution.talk.TEXT_OAKSLAB_OAK1
+local labels = {}
+for i, row in ipairs(script) do
+ if row[1] == "label" then labels[row[2]] = i end
+end
+
+-- mini executor with ScriptRunner semantics: label/number/"end" jumps,
+-- the three checks this branch needs, and text/dex_rating recorded
+local function run(flags, owned, bag)
+ local pc, out, last, guard = 1, {}, nil, 0
+ while pc <= #script and guard < 400 do
+ guard = guard + 1
+ local row = script[pc]
+ local verb, jump = row[1], nil
+ if verb == "check_flag" then last = flags[row[2]] == true
+ elseif verb == "check_dex_owned" then last = owned >= row[2]
+ elseif verb == "check_item" then last = (bag[row[2]] or 0) > 0
+ elseif verb == "jump" then jump = row[2]
+ elseif verb == "jump_if_true" then if last then jump = row[2] end
+ elseif verb == "jump_if_false" then if not last then jump = row[2] end
+ elseif verb == "show_text" then out[#out + 1] = row[2]
+ elseif verb == "dex_rating" then out[#out + 1] = ""
+ end
+ if jump == "end" then break
+ elseif type(jump) == "string" then pc = labels[jump]
+ elseif type(jump) == "number" then pc = jump
+ else pc = pc + 1 end
+ end
+ return table.concat(out, "|")
+end
+
+local dex = "_OaksLabOak1HowIsYourPokedexComingText|"
+
+T.eq(run({ EVENT_GOT_POKEDEX = true }, 2, {}), dex,
+ "Pokédex + 2 owned rates the dex")
+T.eq(run({ EVENT_GOT_POKEDEX = true }, 6, { POKE_BALL = 5 }), dex,
+ "the rating branch wins over the come-see-me line")
+T.eq(run({ EVENT_PALLET_AFTER_GETTING_POKEBALLS = true }, 0, {}), dex,
+ "converted saves rate off EVENT_PALLET_AFTER_GETTING_POKEBALLS")
+T.eq(run({ EVENT_GOT_POKEDEX = true, EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE = true },
+ 5, {}), dex, "the rating branch wins over the poke ball gift")
+
+T.eq(run({ EVENT_GOT_POKEDEX = true }, 1, {}),
+ "_OaksLabOak1PokemonAroundTheWorldText",
+ "one owned species is still the around-the-world line")
+T.eq(run({}, 3, {}), "_OaksLabOak1WhichPokemonDoYouWantText",
+ "no Pokédex yet: never rate, whatever the owned count (Red-only gate)")
+
+T.finish("oaks_lab_dex_rating_bug600")
diff --git a/tests/engine/oaks_lab_last_ball_bug601.lua b/tests/engine/oaks_lab_last_ball_bug601.lua
index 9320da0b..cd7aff42 100644
--- a/tests/engine/oaks_lab_last_ball_bug601.lua
+++ b/tests/engine/oaks_lab_last_ball_bug601.lua
@@ -94,11 +94,18 @@ T.check(box:find("Those are", 1, true) == nil,
T.check(box:find("#MON", 1, true) == nil,
"the ROM #MON ligature is spelled out as Pokémon")
--- the pokered beat also turns Oak to face the player
-T.check(contribution.talk[BALL][20][1] == "face_object"
- and contribution.talk[BALL][20][2] == 5
- and contribution.talk[BALL][20][3] == "down",
- "row 20 faces Oak down before the line (OaksLabLastMonScript)")
+-- the pokered beat also turns Oak to face the player: find the
+-- face_object row in the leftover-ball path (content-based, so a jingle
+-- row added for #668 doesn't shift the hard-coded index)
+local oakFaceRow
+for i, row in ipairs(contribution.talk[BALL]) do
+ if row[1] == "face_object" and row[2] == 5 and row[3] == "down" then
+ oakFaceRow = i
+ break
+ end
+end
+T.check(oakFaceRow ~= nil,
+ "a face_object row turns Oak down before the line (OaksLabLastMonScript)")
-- ---- pre-escort: still the vanilla "Those are POKé BALLs" line
local pre = { EVENT_GOT_STARTER = false, EVENT_FOLLOWED_OAK_INTO_LAB = false }
@@ -115,15 +122,25 @@ T.check(concat(t3):find("last Pokémon!", 1, true) == nil,
"no last-mon line before the pick")
-- ---- all three balls share the same table shape (last-mon beat present)
+-- content-based again: locate the leftover-ball "Pokémon" line wherever
+-- it sits, instead of pinning a row number (#668 added two jingle rows)
for _, key in ipairs({
"TEXT_OAKSLAB_CHARMANDER_POKE_BALL",
"TEXT_OAKSLAB_SQUIRTLE_POKE_BALL",
"TEXT_OAKSLAB_BULBASAUR_POKE_BALL",
}) do
local script = contribution.talk[key]
- T.check(script and script[21] and script[21][2] and
- script[21][2]:find("Pokémon", 1, true) ~= nil,
- key .. " carries the last-mon line")
+ local lastMon
+ if script then
+ for _, row in ipairs(script) do
+ if row[1] == "show_text" and type(row[2]) == "string"
+ and row[2]:find("Pokémon", 1, true) then
+ lastMon = row[2]
+ break
+ end
+ end
+ end
+ T.check(lastMon ~= nil, key .. " carries the last-mon line")
end
T.finish("oaks_lab_last_ball_bug601")
diff --git a/tests/engine/rebind_joystick_bug632.lua b/tests/engine/rebind_joystick_bug632.lua
new file mode 100644
index 00000000..43413b0d
--- /dev/null
+++ b/tests/engine/rebind_joystick_bug632.lua
@@ -0,0 +1,84 @@
+-- Raw-stick rebinding, and the recognized-pad duplicate event that used to
+-- defeat every controller rebind (#632). LOVE raises love.joystickpressed
+-- for EVERY stick, gamepads included, and those raise love.gamepadpressed
+-- for the same press as well; Input's fixed raw table answered both, so it
+-- re-asserted the factory A/B/START/SELECT map underneath a CONTROLS
+-- rebind and a swapped A and B pressed at once. A recognized pad is now
+-- served by the gamepad path alone, and a raw stick's buttons are
+-- rebindable as "joyN" in the same pad slot every other controller uses.
+-- No pokered cite: rebinding is port-only (gap C2).
+-- luajit tests/engine/rebind_joystick_bug632.lua
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.harness")
+local check, eq = T.check, T.eq
+love = love or require("tests.love_stub")
+
+local Input = require("src.core.Input")
+local BindingsMenu = require("src.ui.BindingsMenu")
+
+-- an SDL-recognized pad versus a stick with no database entry
+local pad = { isGamepad = function() return true end }
+local raw = { isGamepad = function() return false end }
+
+Input:init()
+
+-- ---- (a) a recognized pad is served by the gamepad path alone ------------
+Input:reset()
+Input:joystickpressed(pad, 1)
+Input:step()
+check(not Input:isDown("a"),
+ "a recognized pad's duplicate joystick event is ignored (#632)")
+Input:joystickhat(pad, 1, "u")
+Input:step()
+check(not Input:isDown("up"),
+ "and its duplicate hat event is ignored too (#632)")
+
+-- ---- (b) a raw stick keeps its defaults ----------------------------------
+Input:reset()
+Input:joystickpressed(raw, 1)
+Input:step()
+check(Input:isDown("a"), "a raw stick's button 1 still presses A")
+Input:joystickreleased(raw, 1)
+check(not Input:isDown("a"), "and its release clears A")
+
+-- ---- (c) a "joyN" pad binding reaches the raw lookup ---------------------
+Input:applyBindings({ up = { pad = "joy1" } })
+Input:reset()
+Input:joystickpressed(raw, 1)
+Input:step()
+check(Input:isDown("up"), "a joy1 rebind wins the raw button (#632)")
+check(not Input:isDown("a"), "and the raw default no longer presses A")
+Input:joystickreleased(raw, 1)
+Input:applyBindings(nil)
+
+-- ---- (d) the CONTROLS row captures a raw button --------------------------
+-- same doubles as rebind_swap_clear_bug589: a stack the menu can pop itself
+-- off and an input whose queue is one fixed step of edges. data = {} keeps
+-- ChoiceBox's un-guarded Sound.play on the headless no-audio path.
+local game = { save = { options = {} }, data = {} }
+function game:writeOptions() end
+game.stack = {
+ states = {},
+ push = function(self, s) table.insert(self.states, s) end,
+ pop = function(self) return table.remove(self.states) end,
+ top = function(self) return self.states[#self.states] end,
+}
+game.input = { wasPressed = function() return false end,
+ isDown = function() return false end }
+
+local ROW_A = 5 -- BindingsMenu's BUTTONS order
+local bm = BindingsMenu.new(game)
+bm:beginCapture(bm.items[ROW_A])
+bm:onJoystickPressed(3)
+check(game.save.options.bindings == nil,
+ "press alone commits nothing, the release is the commit (#589)")
+bm:onJoystickReleased(3)
+eq(game.save.options.bindings.a.pad, "joy3",
+ "a released raw button lands in the row's pad slot (#632)")
+check(bm.items[ROW_A].right:find("JOY3", 1, true) ~= nil,
+ "and the row's controller column reads JOY3")
+
+Input:init()
+T.finish("rebind_joystick_bug632")
diff --git a/tests/engine/rebind_swap_clear_bug589.lua b/tests/engine/rebind_swap_clear_bug589.lua
index 8efd19d0..0e49d03d 100644
--- a/tests/engine/rebind_swap_clear_bug589.lua
+++ b/tests/engine/rebind_swap_clear_bug589.lua
@@ -17,6 +17,7 @@ love = love or require("tests.love_stub")
local Input = require("src.core.Input")
local Strings = require("src.core.Strings")
+local Timing = require("src.core.Timing")
local BindingsMenu = require("src.ui.BindingsMenu")
-- same doubles as rebind_capture_bug510: a stack the menu can pop itself
@@ -45,6 +46,14 @@ local function press(state, btn)
state.game.input.queue = {}
end
+-- ChoiceBox now holds YES/NO answers on screen for Timing.YES_NO_ANSWER
+-- frames before it commits and pops (DisplayTwoOptionMenu's 15-frame hold,
+-- #GH-627 timing parity); a bare press only arms the choice, so answering
+-- it needs the hold run out before the pop/commit is visible.
+local function settleChoice(state)
+ for _ = 1, Timing.YES_NO_ANSWER do state:update(1 / 60) end
+end
+
-- rows are BindingsMenu's BUTTONS order
local ROW_A, ROW_B, ROW_SELECT = 5, 6, 8
@@ -146,6 +155,7 @@ eq(bm.footer, Strings("RESET ALL BINDINGS?"),
-- the box starts on NO: a bare A press must keep the overlay
press(box, "a")
+settleChoice(box)
eq(game.stack:top(), bm, "answering pops the box")
check(game.save.options.bindings ~= nil, "NO keeps the bindings (defaultNo)")
eq(bm.items[ROW_A].right, "P/B", "and the rows keep showing them")
@@ -155,6 +165,7 @@ press(bm, "start")
box = game.stack:top()
press(box, "up")
press(box, "a")
+settleChoice(box)
check(game.save.options.bindings == nil, "YES clears options.bindings (#589)")
eq(bm.items[ROW_A].right, "Z/A", "the A row reads its default again")
eq(bm.items[ROW_B].right, "X/B", "so does the B row the swap had touched")
diff --git a/tests/engine/safari_gate_bug540.lua b/tests/engine/safari_gate_bug540.lua
new file mode 100644
index 00000000..409032af
--- /dev/null
+++ b/tests/engine/safari_gate_bug540.lua
@@ -0,0 +1,233 @@
+-- Regression coverage for #540 "Safari Zone: wrong start tile / step count,
+-- black leaving-early dialogue, wrong early-leave tile" (T2, ROM-free).
+--
+-- pret/pokered scripts/SafariZoneGate.asm:
+-- .success (186-198) ends `ld a, PAD_UP / ld c, 3 /
+-- SafariZoneEntranceAutoWalk`, so paying walks the player up through the
+-- gate's north warp instead of leaving him at the counter. Two of those
+-- steps happen with EVENT_IN_SAFARI_ZONE already set, and home/overworld.asm
+-- :307-310 charges every step against wSafariSteps, which is why the game
+-- starts at 502 but reads 500/500 on arrival. The port only counts steps on
+-- the nine interior maps, so the script pays those two itself.
+-- SafariZoneGateSafariZoneWorker1LeavingEarlyText (227-254): YES prints the
+-- return-balls text and auto-walks `PAD_DOWN, c = 3` down to the counter row,
+-- NO prints "Good Luck!" and walks back up through the warp.
+--
+-- The leaving-early prompt must be QUEUED, never pushed: onEnter runs at the
+-- arriving warp Transition's midpoint and Transition:finish pops the top state
+-- on the same frame, so a box pushed there is swallowed (or, on an older
+-- build, drawn over a screen still faded to black).
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.harness")
+local MapScripts = require("src.script.MapScripts")
+local ScriptRunner = require("src.script.ScriptRunner")
+
+-- ---- stubs for the two UI modules safari.lua requires lazily. A pushed
+-- box runs its continuation immediately, so a whole prompt chain resolves
+-- inside the push that started it.
+
+local answer = true
+package.loaded["src.render.TextBox"] = {
+ new = function(_, text, done) return { text = text, done = done } end,
+}
+package.loaded["src.ui.ChoiceBox"] = {
+ new = function(_, cb) return { done = function() cb(answer) end } end,
+}
+
+local contribution = dofile("data/scripts/safari.lua")
+local gate = contribution.SAFARI_ZONE_GATE
+T.check(gate ~= nil, "the contribution still carries SAFARI_ZONE_GATE")
+
+local problems = MapScripts.validateContribution(gate)
+T.eq(#problems, 0, "safari contribution validates cleanly")
+for _, p in ipairs(problems) do T.check(false, "unexpected finding: " .. p) end
+
+-- ---- a minimal game/overworld pair
+
+local NORTH_WARP = { x = 4, y = 0, destMap = "SAFARI_ZONE_CENTER", destWarp = 2 }
+
+local function newWorld(cellX, cellY)
+ local w = {
+ pushed = {}, moves = {}, warps = {}, queued = {},
+ player = { cellX = cellX, cellY = cellY },
+ }
+ w.map = {
+ id = "SAFARI_ZONE_GATE",
+ warpAtCell = function(_, cx, cy)
+ if cy == 0 and (cx == 3 or cx == 4) then
+ return { index = cx == 3 and 3 or 4, def = NORTH_WARP }
+ end
+ end,
+ }
+ w.scriptMove = function(_, entity, dir, tiles, onDone)
+ w.moves[#w.moves + 1] = { entity = entity, dir = dir, tiles = tiles,
+ onDone = onDone }
+ end
+ w.takeWarp = function(_, def) w.warps[#w.warps + 1] = def end
+ w.startWarpTo = function(_, mapId, x, y, facing)
+ w.warps[#w.warps + 1] = { startWarpTo = mapId, x = x, y = y,
+ facing = facing }
+ end
+ w.queueScript = function(_, rows) w.queued[#w.queued + 1] = rows end
+ return w
+end
+
+local function newGame(world, money)
+ local game = {
+ data = {
+ text = {},
+ maps = { SAFARI_ZONE_CENTER = {
+ warps = { { x = 14, y = 25 }, { x = 15, y = 25 } } } },
+ },
+ save = { player = { name = "RED" }, money = money or 3000 },
+ }
+ game.stack = { push = function(_, state)
+ world.pushed[#world.pushed + 1] = state
+ if state.done then state.done() end
+ end }
+ return game
+end
+
+-- ---- paying walks into the zone and pays the two gate steps
+
+answer = true
+local ow = newWorld(4, 2)
+local game = newGame(ow)
+T.eq(gate.onStep(game, ow, 4, 2), true, "the trigger cell claims the step")
+T.check(game.save.safari ~= nil, "paying starts the game")
+T.eq(game.save.safari.steps, 502, "wSafariSteps is written as 502")
+T.eq(game.save.safari.balls, 30, "SAFARI_BALLS_RECEIVED is 30")
+T.eq(game.save.money, 2500, "the ¥500 fee is taken")
+
+T.eq(#ow.moves, 1, "the payment text is followed by the entrance auto-walk")
+T.eq(ow.moves[1].dir, "up", "SafariZoneEntranceAutoWalk walks PAD_UP")
+T.eq(ow.moves[1].tiles, 2, "two cells reach the north warp row")
+T.eq(#ow.warps, 0, "the warp is not taken until the walk lands on it")
+ow.moves[1].onDone()
+T.eq(game.save.safari.steps, 500,
+ "the two gate steps are charged, so the counter reads 500/500 on arrival")
+T.eq(ow.warps[1], NORTH_WARP,
+ "a scripted step skips CheckWarpsNoCollision, so the script takes the warp")
+
+-- talking to the worker from anywhere else has no warp above the player, so
+-- the auto-walk stays out of the way and he walks in himself
+answer = true
+local far = newWorld(2, 4)
+local farGame = newGame(far)
+farGame.save.safari = nil
+gate.talk.TEXT_SAFARIZONEGATE_SAFARI_ZONE_WORKER1(farGame, far, nil, nil)
+T.check(farGame.save.safari ~= nil, "the talk path still starts the game")
+T.eq(#far.moves, 0, "no auto-walk from a cell with no warp above it")
+
+-- declining walks the player back off the trigger cell, unchanged
+answer = false
+local no = newWorld(4, 2)
+local noGame = newGame(no)
+gate.onStep(noGame, no, 4, 2)
+T.eq(noGame.save.safari, nil, "declining starts no game")
+T.eq(no.moves[1] and no.moves[1].dir, "down", "declining walks you back down")
+
+-- ---- leaving early: queued, never pushed
+
+local back = newWorld(4, 0)
+local backGame = newGame(back)
+backGame.save.safari = { balls = 7, steps = 300 }
+gate.onEnter(backGame, back)
+T.eq(#back.pushed, 0,
+ "onEnter pushes nothing: the arriving Transition pops the top state on the "
+ .. "same frame")
+T.eq(#back.queued, 1, "the leaving-early prompt is queued for an idle frame")
+
+local rows = back.queued[1]
+T.eq(#ScriptRunner.validate(rows), 0, "the queued rows validate")
+
+local function runRows(script, yes)
+ local pc, texts, out = 1, {}, { fields = {}, moves = {}, warps = {} }
+ local lastCheck = nil
+ while pc <= #script do
+ local row = script[pc]
+ local verb = row[1]
+ local jump = nil
+ if verb == "ask" then
+ texts[#texts + 1] = row[2]
+ lastCheck = yes
+ elseif verb == "show_text" then
+ texts[#texts + 1] = row[2]
+ elseif verb == "jump_if_false" then
+ if not lastCheck then jump = row[2] end
+ elseif verb == "jump" then
+ jump = row[2]
+ elseif verb == "set_field" then
+ out.fields[#out.fields + 1] = { key = row[2], value = row[3] }
+ elseif verb == "move_player" then
+ out.moves[#out.moves + 1] = { dir = row[2], tiles = row[3] }
+ elseif verb == "warp" then
+ out.warps[#out.warps + 1] = { map = row[2], x = row[3], y = row[4],
+ facing = row[5] }
+ end
+ if jump == "end" then break end
+ if jump then
+ local target
+ for i, r in ipairs(script) do
+ if r[1] == "label" and r[2] == jump then target = i break end
+ end
+ T.check(target ~= nil, "jump target '" .. tostring(jump) .. "' exists")
+ pc = target
+ else
+ pc = pc + 1
+ end
+ end
+ out.texts = texts
+ return out
+end
+
+local yes = runRows(rows, true)
+T.eq(yes.texts[1], "_SafariZoneGateSafariZoneWorker1LeavingEarlyText",
+ "the worker asks first")
+T.eq(yes.texts[2], "_SafariZoneGateSafariZoneWorker1ReturnSafariBallsText",
+ "YES takes the leftover balls back")
+T.eq(yes.texts[3], "_SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText",
+ "the good-haul sign-off stays reachable on this branch")
+T.eq(#yes.fields, 1, "the game state is cleared once")
+T.eq(yes.fields[1].key, "safari", "save.safari is the field cleared")
+T.eq(yes.fields[1].value, nil, "set_field with no value assigns nil")
+T.eq(#yes.moves, 1, "YES ends with the exit auto-walk")
+T.eq(yes.moves[1].dir, "down", "SafariZoneEntranceAutoWalk walks PAD_DOWN")
+T.eq(yes.moves[1].tiles, 3, "three cells reach the counter row")
+T.eq(#yes.warps, 0, "the YES branch never warps back into the zone")
+
+local stay = runRows(rows, false)
+T.eq(stay.texts[2], "_SafariZoneGateSafariZoneWorker1GoodLuckText",
+ "NO wishes you good luck")
+T.eq(#stay.moves, 0, "NO does not walk you to the counter")
+T.eq(#stay.warps, 1, "NO puts you back in the zone")
+T.eq(stay.warps[1].map, "SAFARI_ZONE_CENTER", "back through the entrance")
+T.eq(stay.warps[1].x, 15, "the right-hand warp column comes back on column 15")
+T.eq(stay.warps[1].y, 25, "the entrance row of SAFARI_ZONE_CENTER")
+
+-- the left-hand column pairs with the left-hand destination warp
+local left = newWorld(3, 0)
+local leftGame = newGame(left)
+leftGame.save.safari = { balls = 7, steps = 300 }
+gate.onEnter(leftGame, left)
+local leftStay = runRows(left.queued[1], false)
+T.eq(leftStay.warps[1].x, 14, "the left-hand warp column comes back on 14")
+
+-- no game running, or arriving from the town side, asks nothing
+local idle = newWorld(4, 0)
+local idleGame = newGame(idle)
+gate.onEnter(idleGame, idle)
+T.eq(#idle.queued, 0, "no safari game, no prompt")
+
+local south = newWorld(4, 5)
+local southGame = newGame(south)
+southGame.save.safari = { balls = 7, steps = 300 }
+gate.onEnter(southGame, south)
+T.eq(#south.queued, 0, "arriving from Fuchsia asks nothing")
+
+package.loaded["src.render.TextBox"] = nil
+package.loaded["src.ui.ChoiceBox"] = nil
+
+T.finish("safari_gate_bug540")
diff --git a/tests/engine/timing_parity.lua b/tests/engine/timing_parity.lua
new file mode 100644
index 00000000..e08f3a71
--- /dev/null
+++ b/tests/engine/timing_parity.lua
@@ -0,0 +1,630 @@
+-- Timing parity: every sequence in docs/timing-parity.md must cost the same
+-- number of 60Hz logic steps here as it does on hardware.
+--
+-- The port's clock was always right; what drifted was the frame budget of
+-- composed sequences, because the original spends much of its running time
+-- inside DelayFrames calls that produce no visible change. Those are
+-- invisible in a screenshot, so nothing else in the suite catches them --
+-- this file is the only thing standing between the port and a slow slide
+-- back to "snappier than a Game Boy".
+--
+-- Hardware numbers carry their asm citation; regenerate the inventory with
+-- tools/scan_pokered_delays.ps1.
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.modkit")
+local Data = T.fixtures.fresh()
+local Font = require("src.render.Font")
+Font.load(Data)
+
+local Timing = require("src.core.Timing")
+
+-- ---------------------------------------------------------------- constants
+
+-- home/fade.asm: four (or three) palette steps of `ld c, 8 / DelayFrames`
+T.eq(Timing.FADE_OUT_TO_BLACK, 32, "GBFadeOutToBlack is 4 x 8 frames")
+T.eq(Timing.FADE_IN_FROM_BLACK, 32, "GBFadeInFromBlack is 4 x 8 frames")
+T.eq(Timing.FADE_OUT_TO_WHITE, 24, "GBFadeOutToWhite is 3 x 8 frames")
+T.eq(Timing.FADE_IN_FROM_WHITE, 24, "GBFadeInFromWhite is 3 x 8 frames")
+
+T.eq(Timing.DELAY3, 3, "Delay3 is three frames")
+T.eq(Timing.TEXT_SCROLL_PAIR, 10, "ScrollTextUpOneLine is 5 frames, run twice")
+T.eq(Timing.TEXT_CONT, 13, ": ProtectedDelay3 + the two-line scroll")
+T.eq(Timing.TEXT_PARAGRAPH, 23, ": ProtectedDelay3 + DelayFrames 20")
+T.eq(Timing.YES_NO_ANSWER, 15, "DisplayTwoOptionMenu holds 15 frames")
+T.eq(Timing.WARP_FADE_OUT, 32, "a map change fades out over 32 frames")
+T.eq(Timing.WARP_FADE_IN, 0, "there is no fade in: LoadGBPal restores in one write")
+
+-- ---------------------------------------------------------------- HP bar
+
+-- UpdateHPBar walks one HP point per iteration. On the player's HUD each
+-- point costs a frame (PrintHPNumber's DelayFrame, gated on wHPBarType) and
+-- each pixel of bar movement costs two more; on the enemy HUD only the
+-- pixels cost anything.
+T.eq(Timing.hpBarPixels(150, 150), 48, "a full bar is 48 px")
+T.eq(Timing.hpBarPixels(75, 150), 24, "half HP is half the bar")
+T.eq(Timing.hpBarPixels(0, 150), 0, "an empty bar is 0 px")
+T.eq(Timing.hpBarPixels(1, 150), 1, "GetHPBarLength clamps a sliver to 1 px")
+
+T.eq(Timing.hpDrainFrames(150, 0, 150, true), 150 + 96 + 6,
+ "a 150 HP player mon drains in D + 2P + 6 = 252 frames")
+T.eq(Timing.hpDrainFrames(150, 0, 150, false), 96 + 5,
+ "the same drain on the enemy HUD costs only 2P + 5 = 101 frames")
+
+-- The engine's per-frame stepper has to agree with that closed form, or the
+-- bar is animating at a rate nothing else measures.
+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)
+
+-- Each case gets its own party: draining a battler to 0 faints the very
+-- Pokemon object the save holds, and newWild refuses to start with no
+-- healthy party.
+local function newBattle()
+ 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 } }
+ return BattleState.newWild(game, "FIXMON_C", 40)
+end
+
+local function drainFrames(battle, battler, toHP)
+ battler.mon.hp = toHP
+ local frames = 0
+ while battle:stepHPDrain() and frames < 20000 do frames = frames + 1 end
+ return frames
+end
+
+local battle = newBattle()
+local pMax = battle.player.mon.stats.hp
+T.eq(drainFrames(battle, battle.player, 0),
+ Timing.hpDrainFrames(pMax, 0, pMax, true),
+ "the player's bar steps at the hardware rate")
+
+local battle2 = newBattle()
+local eMax = battle2.enemy.mon.stats.hp
+T.eq(drainFrames(battle2, battle2.enemy, 0),
+ Timing.hpDrainFrames(eMax, 0, eMax, false),
+ "the enemy's bar steps at the hardware rate")
+
+-- a partial drain is exact too: every player-side HP step costs at least the
+-- number print, so the stepper never batches two into one frame
+local battle3 = newBattle()
+local start = battle3.player.mon.hp
+local target = math.max(1, start - 7)
+T.eq(drainFrames(battle3, battle3.player, target),
+ Timing.hpDrainFrames(start, target, battle3.player.mon.stats.hp, true),
+ "a partial player drain matches the closed form")
+
+-- ---------------------------------------------------------------- text box
+
+-- Both and print the down-arrow and run ProtectedDelay3 before
+-- ManualTextScroll starts watching the joypad, then pay the scroll or the
+-- box clear after the button. The port used to advance on the press frame
+-- with no cost on either side.
+local TextBox = require("src.render.TextBox")
+
+local Input = { down = {}, pressed = {} }
+function Input:isDown(b) return self.down[b] or false end
+function Input:wasPressed(b) return self.pressed[b] or false end
+function Input:press(b) self.pressed[b] = true end
+function Input:release() self.pressed = {} end
+
+local function textGame()
+ local popped = false
+ local g = { data = Data, save = SaveData.newGame(), input = Input }
+ g.stack = { push = function() end,
+ pop = function() popped = true end,
+ top = function() return nil end }
+ g.wasPopped = function() return popped end
+ return g
+end
+
+-- type a box out to its first wait, returning the frames that took
+local function typeToWait(box)
+ local frames = 0
+ while not box.waiting and not box.done and frames < 2000 do
+ box:update(1 / 60)
+ frames = frames + 1
+ end
+ return frames
+end
+
+local g = textGame()
+g.save.options = g.save.options or {}
+g.save.options.textSpeed = 1 -- fastest, so the typewriter is not the subject
+local box = TextBox.new(g, "AB\fCD", {})
+typeToWait(box)
+T.check(box.waiting, "the box waits at the page break")
+
+-- the three ProtectedDelay3 frames swallow the button
+local held = 0
+for _ = 1, Timing.TEXT_PRE_ADVANCE do
+ Input:press("a")
+ box:update(1 / 60)
+ Input:release()
+ held = held + 1
+ T.check(box.waiting, "still waiting on pre-advance frame " .. held)
+end
+
+-- the press now lands, and the box holds for the clear before typing again
+Input:press("a")
+box:update(1 / 60)
+Input:release()
+T.eq(box.holdFrames, Timing.TEXT_PAGE_CLEAR,
+ "a page break holds DelayFrames 20 after the press")
+T.eq(#box.shown[1], 0, "the new page has not typed a character yet")
+
+local blocked = 0
+while (box.holdFrames or 0) > 0 and blocked < 200 do
+ box:update(1 / 60)
+ blocked = blocked + 1
+ T.eq(#box.shown[#box.shown], 0, "nothing types during the hold")
+end
+T.eq(blocked, Timing.TEXT_PAGE_CLEAR, "the hold is exactly 20 frames")
+
+-- pays the two-line scroll instead of the clear
+local g2 = textGame()
+g2.save.options = g2.save.options or {}
+g2.save.options.textSpeed = 1
+local box2 = TextBox.new(g2, "AB\vCD", {})
+typeToWait(box2)
+T.check(box2.waiting, "the box waits at the CONT marker")
+for _ = 1, Timing.TEXT_PRE_ADVANCE do box2:update(1 / 60) end
+Input:press("a")
+box2:update(1 / 60)
+Input:release()
+T.eq(box2.holdFrames, Timing.TEXT_SCROLL_PAIR,
+ "a CONT advance holds for the two ScrollTextUpOneLine calls")
+
+-- ---------------------------------------------------------------- yes/no
+
+local ChoiceBox = require("src.ui.ChoiceBox")
+
+local chosen, popped = nil, 0
+local g3 = { data = Data, save = SaveData.newGame(), input = Input }
+g3.stack = { push = function() end, pop = function() popped = popped + 1 end,
+ top = function() return nil end }
+local choice = ChoiceBox.new(g3, function(yes) chosen = yes end)
+
+Input:press("a")
+choice:update(1 / 60)
+Input:release()
+T.eq(chosen, nil, "the answer does not fire on the press frame")
+
+-- count only the frames after the press: DelayFrames 15 runs between the
+-- press and TwoOptionMenu_RestoreScreenTiles handing control back
+local waited = 0
+while chosen == nil and waited < 200 do
+ choice:update(1 / 60)
+ waited = waited + 1
+end
+T.eq(waited, Timing.YES_NO_ANSWER, "the answer fires 15 frames after the press")
+T.eq(chosen, true, "A chose YES")
+T.eq(popped, 1, "the box popped itself once")
+
+-- B picks the second option, and the cursor snaps to it for the hold
+local chosen2 = nil
+local g4 = { data = Data, save = SaveData.newGame(), input = Input }
+g4.stack = { push = function() end, pop = function() end,
+ top = function() return nil end }
+local choice2 = ChoiceBox.new(g4, function(yes) chosen2 = yes end)
+Input:press("b")
+choice2:update(1 / 60)
+Input:release()
+T.eq(choice2.index, 2, "B moves the cursor to NO before the hold")
+local waited2 = 0
+while chosen2 == nil and waited2 < 200 do
+ choice2:update(1 / 60)
+ waited2 = waited2 + 1
+end
+T.eq(waited2, Timing.YES_NO_ANSWER, "the B answer holds 15 frames too")
+T.eq(chosen2, false, "B chose NO")
+
+-- ---------------------------------------------------------------- warp fade
+
+-- PlayMapChangeSound fades out over 32 frames and the new map simply
+-- appears; the port used to run a symmetric 12/12 fade, which is both too
+-- fast and a shape the hardware never had.
+local Transition = require("src.render.Transition")
+
+local mid, done, tpopped = 0, 0, 0
+local g5 = { data = Data, save = SaveData.newGame() }
+g5.stack = { push = function() end, pop = function() tpopped = tpopped + 1 end,
+ top = function() return nil end }
+local fade = Transition.new(g5, function() mid = mid + 1 end,
+ function() done = done + 1 end)
+
+local f = 0
+while done == 0 and f < 500 do
+ fade:update(1 / 60)
+ f = f + 1
+ if mid == 1 and done == 0 then
+ T.check(false, "the map switch and the hand-back must land together")
+ end
+end
+T.eq(f, Timing.WARP_FADE_OUT, "the warp fade is 32 frames end to end")
+T.eq(mid, 1, "the map switched once")
+T.eq(done, 1, "the transition handed control back once")
+T.eq(tpopped, 1, "and popped itself once")
+
+-- ------------------------------------------------------- battle turns
+
+-- PlayApplyingAttackAnimation's six types (animations.asm:490-524). The
+-- slow shakes are c * 4b because AnimationShakeScreenHorizontallySlow pushes
+-- bc twice and runs two b-loops of DelayFrames 2 per outer pass.
+T.eq(Timing.SHAKE_VERTICAL, 48, "type 1 ShakeScreenVertically b=8")
+T.eq(Timing.SHAKE_HORIZ_HEAVY, 72, "type 2 fast horizontal b=8")
+T.eq(Timing.SHAKE_HORIZ_SLOW, 48, "type 3 slow horizontal, lb bc, 6, 2")
+T.eq(Timing.SHAKE_HORIZ_LIGHT, 18, "type 5 fast horizontal b=2")
+T.eq(Timing.SHAKE_HORIZ_SLOW2, 24, "type 6 slow horizontal, lb bc, 3, 2")
+
+-- Type 4 is the player's plain damaging move -- the most-seen animation in
+-- the game. AnimationBlinkMon is `ld c, 6` of hide/DelayFrames 5/show/
+-- DelayFrames 5 (animations.asm:1360-1376): 60 frames, not the 20 the port
+-- used to run.
+T.eq(Timing.BLINK_MON, 60, "AnimationBlinkMon is 6 x (5 hidden + 5 shown)")
+T.eq(Timing.BLINK_MON % 10, 0,
+ "and divides into whole 10-frame blinks, matching fxHidden's period")
+
+-- SlideDownFaintedMonPic: b = PIC_HEIGHT slide steps of DelayFrames 2
+T.eq(Timing.FAINT_SLIDE, 14, "the faint slide is 7 steps x 2 frames")
+
+-- #671: the slide must start at the sprite's resting spot and sink the
+-- full PIC_HEIGHT (7 rows x 8px = 56px at 1x) across those 14 frames.
+-- The old (30 - frames) * 2 math teleported the pic 32px down on frame
+-- one once the budget was shortened from 30 to 14 frames.
+local faintBattle = newBattle()
+faintBattle.fx = { faint = { battler = faintBattle.enemy,
+ frames = Timing.FAINT_SLIDE } }
+T.eq(faintBattle:fxFaintOffset(faintBattle.enemy, 1), 0,
+ "the faint slide starts at offset 0 (#671)")
+faintBattle.fx.faint.frames = Timing.FAINT_SLIDE - 7
+T.eq(faintBattle:fxFaintOffset(faintBattle.enemy, 1),
+ 7 * Timing.FAINT_SLIDE_STEP,
+ "the slide sinks one 8px row per step")
+faintBattle.fx.faint.frames = 1
+T.eq(faintBattle:fxFaintOffset(faintBattle.enemy, 1),
+ (Timing.FAINT_SLIDE - 1) * Timing.FAINT_SLIDE_STEP,
+ "the slide reaches 52px by the last visible frame")
+T.eq(Timing.FAINT_SLIDE * Timing.FAINT_SLIDE_STEP, 56,
+ "and covers the full 7-row pic height over the whole budget")
+
+T.eq(Timing.MOVE_STATUS_OR_MISS, 30,
+ "a status move or a miss holds DelayFrames 30 before its text")
+
+T.eq(Timing.MOVE_ANIM_PRE, 3,
+ "PlayMoveAnimation calls Delay3 before handing off to MoveAnimation")
+T.eq(Timing.CRIT_OHKO_TEXT, 20,
+ "PrintCriticalOHKOText closes with DelayFrames 20")
+T.eq(Timing.BATTLE_START_SENDOUT, 40, "StartBattle holds 40 after the send-out")
+
+-- An animation row pays PlayMoveAnimation's Delay3 before the first frame
+-- of the animation, so the row goes back on the queue once (core.asm:6638).
+do
+ local b = newBattle()
+ b.queue, b.nextInsert, b.current = {}, 0, nil
+ b.waitFrames, b.waitingSound = nil, nil
+ b.draining, b.animPlaying, b.waitingUI = nil, nil, nil
+ b.queue[1] = { anim = "FIX_TACKLE", attackerIsPlayer = true }
+ b:updateQueue()
+ T.eq(b.waitFrames, Timing.MOVE_ANIM_PRE,
+ "the anim row pays Delay3 before it plays")
+ T.eq(#b.queue, 1, "and is put back on the queue to run after the hold")
+ T.check(b.queue[1].animDelayed, "flagged so the hold is paid only once")
+end
+
+-- StartBattle's 40-frame hold is unconditional -- the `call nz` gates only
+-- EnemySendOutFirstMon -- so a wild battle queues it too, between
+-- "Wild X appeared!" and "Go! Y!" (core.asm:152-156).
+do
+ -- the intro queue is built by enter(), not by newWild
+ local b = newBattle()
+ local ok = pcall(b.enter, b)
+ T.check(ok, "a wild battle's intro builds")
+ local found = false
+ for _, row in ipairs(b.queue) do
+ if row.wait == Timing.BATTLE_START_SENDOUT then found = true break end
+ end
+ T.check(found, "a wild battle's intro queues the 40-frame send-out hold")
+end
+
+-- waitNext is what puts that hold in the turn queue
+do
+ local b = newBattle()
+ b.queue, b.nextInsert = {}, 0
+ b:waitNext(Timing.MOVE_STATUS_OR_MISS)
+ T.eq(#b.queue, 1, "waitNext queues one row")
+ T.eq(b.queue[1].wait, Timing.MOVE_STATUS_OR_MISS, "carrying the hold length")
+ b:waitNext(0)
+ T.eq(#b.queue, 1, "a zero-length hold queues nothing")
+end
+
+-- Battle text prints through the same PrintText path as overworld text, so
+-- it pays PrintLetterDelay per character (home/print_text.asm:4-45): one
+-- glyph per wOptions & $f frames, collapsing to one frame while A or B is
+-- held. It used to run a flat two glyphs per frame -- six times hardware
+-- speed at the default setting -- and ignored the text-speed option.
+local function typedFrames(speed, hold)
+ local Btn = { down = {}, pressed = {} }
+ function Btn:isDown(k) return self.down[k] or false end
+ function Btn:wasPressed(k) return self.pressed[k] or false end
+ if hold then Btn.down.a = true end
+
+ local b = newBattle()
+ b.game.input = Btn
+ b.game.save.options = b.game.save.options or {}
+ b.game.save.options.textSpeed = speed
+ b.queue, b.nextInsert = {}, 0
+ b.waitFrames, b.waitingSound = nil, nil
+ b.draining, b.animPlaying, b.waitingUI = nil, nil, nil
+ b:startMessage({ text = "ABCDEF" })
+ local frames = 0
+ while (b.charIndex or 0) < 6 and frames < 400 do
+ b:updateQueue()
+ frames = frames + 1
+ end
+ return frames
+end
+
+T.eq(typedFrames(3), 18, "six glyphs at MEDIUM take 3 frames each")
+T.eq(typedFrames(5), 30, "six glyphs at SLOW take 5 frames each")
+T.eq(typedFrames(1), 6, "six glyphs at FAST take 1 frame each")
+T.eq(typedFrames(3, true), 6,
+ "holding A collapses the per-letter wait to a single frame")
+
+-- WaitForSoundToFinish (home/delay.asm:15-20) is how the original gives a
+-- sound its own clear window. A trainer intro plays SFX_Silph_Scope --
+-- extracted here as "Trainer_Appeared" -- blocks on it, and only then pays
+-- the DelayFrames 20 before the balls and the text.
+do
+ local b = newBattle()
+ b.queue, b.nextInsert, b.current = {}, 0, nil
+ b.waitFrames, b.waitingSound = nil, nil
+ b.draining, b.animPlaying, b.waitingUI = nil, nil, nil
+ local playing = true
+ local src = { isPlaying = function() return playing end }
+ b.queue[1] = { waitSound = function() return src end }
+ T.check(b:updateQueue(), "the waitSound row is taken off the queue")
+ T.eq(b.waitingSound, src, "and parks the queue on that source")
+ T.check(b:updateQueue(), "the queue blocks while the sound is audible")
+ playing = false
+ b:updateQueue()
+ T.eq(b.waitingSound, nil, "and releases the frame the sound stops")
+end
+
+-- Every battle enters through the wipe, script-driven ones included.
+-- BattleTransition runs from DoBattleTransitionAndInitBattleVariables for
+-- all of them; start_battle used to push the BattleState straight onto the
+-- stack, so every scripted trainer -- gym leaders, the rival -- and every
+-- scripted wild battle cut to the battle screen with no transition at all.
+do
+ local Commands = require("src.script.Commands")
+ local route = {}
+ local save = SaveData.newGame()
+ save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
+ local ctx = {
+ game = { data = Data, save = save,
+ stack = { push = function() route[#route + 1] = "raw" end,
+ top = function() return nil end } },
+ runner = { yield = function() end, resume = function() end },
+ overworld = { pushBattle = function() route[#route + 1] = "wipe" end },
+ }
+ Commands.start_battle(ctx, "wild", "FIXMON_C", 5)
+ T.eq(#route, 1, "start_battle pushes the battle exactly once")
+ T.eq(route[1], "wipe", "and routes it through the transition wipe")
+
+ -- with no overworld (a headless or menu-driven caller) it still works
+ local route2 = {}
+ local ctx2 = {
+ game = { data = Data, save = save,
+ stack = { push = function() route2[#route2 + 1] = "raw" end,
+ top = function() return nil end } },
+ runner = { yield = function() end, resume = function() end },
+ }
+ Commands.start_battle(ctx2, "wild", "FIXMON_C", 5)
+ T.eq(route2[1], "raw", "and falls back to a direct push with no overworld")
+end
+
+-- --------------------------------------------------- battle transition
+
+-- The eight wipes, from pokered-c's battle_transition.c budget (derived from
+-- battle_transitions.asm, then checked against the ROM side by side). The
+-- port used a flat 40/24 for all eight.
+local BT = require("src.render.BattleTransition")
+local WIPES = {
+ doublecircle = 30, -- 10 steps x 3 frames
+ circle = 60, -- 20 x 3
+ spiralout = 120, -- 360 fills / 3 per frame
+ hstripes = 60, -- 20 x 3
+ vstripes = 54, -- 18 x 3
+ shrink = 54, -- 9 x 6
+ split = 54, -- 9 x 6
+}
+for id, frames in pairs(WIPES) do
+ T.eq(BT.STYLES[id].frames, frames, id .. " runs its asm frame budget")
+end
+
+-- The inward spiral writes one tile per iteration and calls
+-- BattleTransition_TransferDelay3 every seventh -- and that helper is a
+-- Delay3, three frames, not a one-frame transfer. Reading it as one frame
+-- gives ~46 frames against the ROM's ~150, which is the exact mistake
+-- pokered-c caught on a live comparison.
+T.eq(BT.STYLES.spiralin.frames % 3, 0,
+ "the inward spiral advances in whole Delay3 units")
+T.check(BT.STYLES.spiralin.frames >= 130 and BT.STYLES.spiralin.frames <= 170,
+ "the inward spiral lands near the ROM's ~150 frames, not the ~46 a "
+ .. "one-frame transfer would give (got "
+ .. tostring(BT.STYLES.spiralin.frames) .. ")")
+
+-- The flash belongs to the two wild wipes only: BattleTransition_FlashScreen
+-- is called from BattleTransition_Circle (:585) and _DoubleCircle (:628) and
+-- nowhere else. A trainer battle's transition is the spiral, inward against
+-- a weaker foe and outward against a stronger one
+-- (wBattleTransitionSpiralDirection, :119-126).
+do
+ local fakeRenderer = {}
+ local g = { data = Data, save = SaveData.newGame(), renderer = fakeRenderer,
+ stack = { push = function() end, pop = function() end,
+ top = function() return nil end } }
+
+ local wild = BT.new(g, function() end, { trainer = false, stronger = false })
+ T.eq(wild.style, "doublecircle", "a weak wild foe gets the double circle")
+ T.eq(wild.phase, "flash", "and flashes before the wipe")
+
+ local weak = BT.new(g, function() end, { trainer = true, stronger = false })
+ T.eq(weak.style, "spiralin", "a weaker trainer gets the inward spiral")
+ T.eq(weak.phase, "wipe", "with no flash in front of it")
+
+ local strong = BT.new(g, function() end, { trainer = true, stronger = true })
+ T.eq(strong.style, "spiralout", "a stronger trainer gets the outward spiral")
+
+ -- the flash is a palette write, so it veils the whole surface: it is
+ -- handed to the renderer in screen space rather than filling the 160x144
+ -- UI canvas, which at any zoom above 1x left the surround unlit
+ wild:draw()
+ T.check(fakeRenderer.screenVeil ~= nil,
+ "the flash publishes a screen-space veil to the renderer")
+ T.eq(#fakeRenderer.screenVeil, 2, "as a {shade, alpha} pair")
+end
+
+-- Returning to the overworld after a battle is a fade, not a cut:
+-- DelayFrames 10 (home/overworld.asm:351-352) with the palettes still white,
+-- then MapEntryAfterBattle's GBFadeInFromWhite (:749-753) = 24 frames.
+do
+ local popped, done = 0, 0
+ local r = {}
+ local g = { data = Data, save = SaveData.newGame(), renderer = r,
+ stack = { push = function() end,
+ pop = function() popped = popped + 1 end,
+ top = function() return nil end } }
+ local fade = require("src.render.Transition").battleReturn(g,
+ function() done = done + 1 end)
+
+ -- solid white through the 10-frame hold
+ for i = 1, Timing.POST_BATTLE_RETURN do
+ fade:draw()
+ T.eq(r.screenVeil[1], 1, "the veil is white on hold frame " .. i)
+ T.eq(r.screenVeil[2], 1, "and fully opaque on hold frame " .. i)
+ fade:update(1 / 60)
+ end
+
+ -- then it steps off in three palette stages of 8 frames, the way
+ -- GBFadeIncCommon writes a palette and holds it (home/fade.asm:30-41)
+ fade:draw()
+ T.eq(r.screenVeil[2], 2 / 3, "the first fade step drops to two thirds")
+ for _ = 1, 8 do fade:update(1 / 60) end
+ fade:draw()
+ T.eq(r.screenVeil[2], 1 / 3, "the second step to one third")
+ for _ = 1, 8 do fade:update(1 / 60) end
+ fade:draw()
+ T.eq(r.screenVeil[2], 0, "the third clears it")
+
+ -- total duration, measured on a fresh instance (the staircase checks above
+ -- advanced this one)
+ local popped2, done2 = 0, 0
+ local g2 = { data = Data, save = SaveData.newGame(), renderer = {},
+ stack = { push = function() end,
+ pop = function() popped2 = popped2 + 1 end,
+ top = function() return nil end } }
+ local fade2 = require("src.render.Transition").battleReturn(g2,
+ function() done2 = done2 + 1 end)
+ local frames = 0
+ while done2 == 0 and frames < 500 do
+ fade2:update(1 / 60)
+ frames = frames + 1
+ end
+ T.eq(frames, Timing.POST_BATTLE_RETURN + Timing.FADE_IN_FROM_WHITE,
+ "the whole return is the 10-frame hold plus a 24-frame fade")
+ T.eq(popped2, 1, "and it pops itself exactly once")
+ T.check(popped >= 0, "the staircase instance is independent")
+end
+
+-- The spiral and circle walks generalise to an arbitrary grid, so a zoomed
+-- or windowed surface wipes as one figure instead of a spiral in a box
+-- surrounded by a square cascade.
+do
+ local COLS_GB, ROWS_GB = 20, 18 -- the Game Boy's own tile grid
+ for _, style in ipairs({ "spiralin", "spiralout", "circle", "doublecircle" }) do
+ local order = BT.gridOrder(style, 40, 23)
+ T.check(order ~= nil, style .. " builds an order for an arbitrary grid")
+ T.eq(#order, 40 * 23, style .. " covers every tile of a 40x23 grid")
+ local seen, dup = {}, false
+ for _, t in ipairs(order) do
+ local k = t[1] .. "," .. t[2]
+ if seen[k] then dup = true end
+ seen[k] = true
+ end
+ T.check(not dup, style .. " visits each tile exactly once")
+ end
+ T.eq(BT.gridOrder("shrink", 40, 23), nil,
+ "geometry-shaped styles have no tile order and are extended as rects")
+ -- At exactly the Game Boy's grid, gridOrder hands back the ROM's own walk
+ -- rather than the generic one -- so an unzoomed window is the classic wipe.
+ -- BattleTransition_InwardSpiral fills 359 of the 360 tiles and leaves the
+ -- centre one to the final blackout, which is how you tell the two apart.
+ T.eq(#BT.gridOrder("spiralin", COLS_GB, ROWS_GB), 359,
+ "the classic grid gets the ROM's walk, not the generic spiral")
+ T.check(#BT.gridOrder("spiralin", COLS_GB + 1, ROWS_GB)
+ == (COLS_GB + 1) * ROWS_GB,
+ "one tile wider and it is the generic spiral, covering everything")
+ -- degenerate grids must not hang or error
+ T.eq(#BT.gridOrder("spiralin", 1, 1), 1, "a 1x1 grid is one tile")
+ T.eq(#BT.gridOrder("spiralout", 3, 1), 3, "a single-row grid walks straight")
+end
+
+-- SlidePlayerAndEnemySilhouettesOnScreen: 144 px at 2 px/frame
+T.eq(Timing.BATTLE_SLIDE_IN_FRAMES, 72, "the silhouettes slide for 72 frames")
+T.eq(Timing.BATTLE_SLIDE_PX_PER_FRAME, 2, "at 2 px per frame")
+T.eq(Timing.BATTLE_SLIDE_IN_FRAMES * Timing.BATTLE_SLIDE_PX_PER_FRAME, 144,
+ "which is the SCX $90 the enemy side scrolls through")
+
+T.eq(Timing.TRAINER_INTRO_SFX_GAP, 20,
+ "a trainer intro waits DelayFrames 20 before the balls and the text")
+
+-- ------------------------------------------------------- catch-up clamping
+
+-- Removing the warp fade in took away the counter that used to absorb the
+-- map-load hitch, so discardCatchup has to handle the oversized dt the hitch
+-- produces on the FOLLOWING frame -- otherwise the burst just moves one
+-- frame later and shows up as a walk-animation slide (issue #93).
+local FixedStep = require("src.core.FixedStep")
+
+local steps = 0
+FixedStep:init(function() steps = steps + 1 end)
+FixedStep:update(1 / 60)
+T.eq(steps, 1, "an ordinary frame runs exactly one logic step")
+
+-- what the hitch does when nothing is armed
+steps = 0
+FixedStep:update(0.25)
+T.check(steps > 10,
+ "an unclamped hitch frame burns a burst of steps before the next draw")
+
+-- and with the clamp armed
+FixedStep:init(function() steps = steps + 1 end)
+FixedStep:discardCatchup()
+steps = 0
+FixedStep:update(0.25)
+T.eq(steps, 1, "the frame after discardCatchup is clamped to one step")
+
+steps = 0
+FixedStep:update(1 / 60)
+T.eq(steps, 1, "and the clamp expires after that one frame")
+
+-- ...and the absorbed frame must not hand the accumulator back sitting on a
+-- step boundary. A residual of zero has no margin on the long side, so the
+-- accumulator settles a hair under one step and stays there, flipping between
+-- 0 and 2 steps on sub-millisecond frame wobble for the rest of the session,
+-- which is what made pacing erratic forever after a route seam (issue #487).
+FixedStep:init(function() steps = steps + 1 end)
+FixedStep:discardCatchup()
+FixedStep:update(0.25)
+T.check(FixedStep.accum > FixedStep.STEP * 0.25
+ and FixedStep.accum < FixedStep.STEP * 0.75,
+ "the absorbed hitch frame leaves the accumulator mid-step, not on a boundary")
+
+T.finish("timing parity")
diff --git a/tests/engine/title_fill_scale.lua b/tests/engine/title_fill_scale.lua
new file mode 100644
index 00000000..4ad03d40
--- /dev/null
+++ b/tests/engine/title_fill_scale.lua
@@ -0,0 +1,50 @@
+-- The title screen and the intro fill the window (aspect preserved, bars on
+-- the long axis) instead of sitting at the fixed integer scale, and the
+-- overworld's survey zoom does not shrink them.
+--
+-- Both halves shipped broken together. Renderer:uiScale steps the UI down one
+-- whole integer per zoom-out step, which is right for the overworld -- a
+-- full-size dialogue box over a shrunken map looks wrong -- but it was applied
+-- unconditionally, so a saved zoom of -2 also drew the TITLE SCREEN at a
+-- reduced scale, in a window showing no map at all. The fix gates the
+-- step-down on a world actually being on screen, and opts these two states
+-- into the fill scale the battle "fill" size already uses.
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.modkit")
+local Game = require("src.core.Game")
+local TitleState = require("src.ui.TitleState")
+local IntroMovie = require("src.ui.IntroMovie")
+
+-- --------------------------------------------------------------- the opt-in
+
+local title = setmetatable({}, { __index = TitleState })
+local intro = setmetatable({}, { __index = IntroMovie })
+
+T.eq(title:wantsFillScale(), true, "the title screen asks for the fill scale")
+T.eq(intro:wantsFillScale(), true, "and so does the intro")
+
+-- neither reads options, so this must hold with no game attached at all --
+-- the title screen is up before a save is loaded
+T.eq(TitleState.wantsFillScale(nil), true,
+ "the title screen fills with no game or save behind it")
+T.eq(IntroMovie.wantsFillScale(nil), true, "and so does the intro")
+
+-- ------------------------------------------------------------- the stack scan
+
+local function stack(...) return { states = { ... } } end
+local overworld = {} -- no wantsFillScale at all, like every other state
+
+T.eq(Game.fillScaleInStack(stack(title)), true,
+ "the shared scan picks the title screen up")
+T.eq(Game.fillScaleInStack(stack(intro)), true, "and the intro")
+T.eq(Game.fillScaleInStack(stack(overworld)), false,
+ "and the overworld still draws at the fixed scale")
+
+-- the title screen opens the CONTINUE/NEW GAME menu and the options menu on
+-- top of itself; those must not snap the surface back for a frame, the same
+-- whole-stack rule a battle relies on
+T.eq(Game.fillScaleInStack(stack(title, {})), true,
+ "a menu opened over the title screen keeps it filling")
+
+T.finish("title fill scale")
diff --git a/tests/engine/touch_orientation_bug633.lua b/tests/engine/touch_orientation_bug633.lua
new file mode 100644
index 00000000..48e87da3
--- /dev/null
+++ b/tests/engine/touch_orientation_bug633.lua
@@ -0,0 +1,80 @@
+-- Per-orientation touch layouts and the button size setting (#633). The
+-- overlay used to keep exactly one positions table, so laying the pad out
+-- in landscape rewrote the portrait layout; now each orientation owns a
+-- {positions, scale} bucket picked from the safe rect's aspect, and the
+-- editor's -/+ scales every control in the active one. No pokered cite:
+-- the on-screen pad is a port-only affordance (Xelu CC0 art).
+-- luajit tests/engine/touch_orientation_bug633.lua
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.harness")
+local check, eq = T.check, T.eq
+love = love or require("tests.love_stub")
+
+local TC = require("src.core.TouchControls")
+
+-- per-orientation buckets round-trip independently, scale is clamped
+local cfg = TC.normalizeConfig({
+ layouts = {
+ portrait = { positions = { dpad = { x = 0.2, y = 0.9 } }, scale = 1.25 },
+ landscape = { positions = { dpad = { x = 0.1, y = 0.5 } }, scale = 99 },
+ },
+})
+eq(cfg.layouts.portrait.positions.dpad.x, 0.2, "portrait bucket kept")
+eq(cfg.layouts.landscape.positions.dpad.x, 0.1, "landscape bucket kept")
+eq(cfg.layouts.portrait.scale, 1.25, "portrait scale kept")
+eq(cfg.layouts.landscape.scale, TC.SCALE_MAX, "out-of-range scale clamps")
+
+-- steer the safe rect by hand: tall = portrait, wide = landscape
+love.window = love.window or {}
+local oldSafe = love.window.getSafeArea
+local function setRect(w, h)
+ love.window.getSafeArea = function() return 0, 0, w, h end
+ -- getDimensions bounds the safe rect (SafeArea clamps to the drawable
+ -- window), so keep both in step
+ love.graphics.getDimensions = function() return w, h end
+ TC.layoutW, TC.layoutH, TC.layoutOx, TC.layoutOy, TC.L = nil, nil, nil, nil, nil
+end
+
+TC:init()
+setRect(380, 720)
+TC:applyOptions({
+ touchControls = {
+ enabled = true,
+ positions = { dpad = { x = 0.25, y = 0.75 } },
+ },
+})
+
+-- the size setting scales every control in the active (portrait) bucket
+TC:setScale(1.5)
+setRect(380, 720)
+local big = TC:layout()
+eq(TC.orientation, "portrait", "tall safe rect is portrait")
+check(big.a.w > TC.defaultLayout(380, 720).a.w, "scale grows buttons")
+
+-- rotating swaps buckets, and a landscape drag leaves portrait alone
+TC:setScale(1)
+setRect(800, 400)
+TC:layout()
+eq(TC.orientation, "landscape", "wide safe rect is landscape")
+TC:setControlCenter("dpad", 700, 300)
+eq(TC.layouts.portrait.positions.dpad.x, 0.25,
+ "landscape drag leaves the portrait layout alone (#633)")
+
+-- Reset clears only the orientation on screen
+TC:clearPositions()
+check(TC.layouts.landscape.positions == nil,
+ "Reset wipes the landscape overrides")
+eq(TC.layouts.portrait.positions.dpad.x, 0.25,
+ "and the portrait layout survives it (#633)")
+
+-- config() snapshots both buckets for the editor's save path
+local snap = TC:config()
+eq(snap.layouts.portrait.positions.dpad.x, 0.25, "config carries portrait")
+check(snap.layouts.landscape.positions == nil, "config carries landscape")
+
+if oldSafe then love.window.getSafeArea = oldSafe
+else love.window.getSafeArea = nil end
+
+T.finish("touch_orientation_bug633")
diff --git a/tests/engine/ui_layout_option.lua b/tests/engine/ui_layout_option.lua
new file mode 100644
index 00000000..7620018d
--- /dev/null
+++ b/tests/engine/ui_layout_option.lua
@@ -0,0 +1,123 @@
+-- UI LAYOUT (save.options.uiLayout): "centered" keeps every element where it
+-- was drawn in the 160x144 canvas, so the letterbox centres the whole screen
+-- the way the port composed it before edge docking existed. "dynamic" opts
+-- into docking: the dialogue box to the window's bottom edge, the START menu
+-- to its top right.
+--
+-- Centered is the DEFAULT. Docking is a real change to where screen
+-- furniture sits, so it is opt-in rather than something a player has to
+-- discover and turn off.
+--
+-- One gate, at Renderer:setUIAnchor, so the switch covers the dialogue box,
+-- its YES/NO, the START menu and anything added later without any of them
+-- knowing the option exists.
+-- luajit tests/engine/ui_layout_option.lua
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.modkit")
+local Game = require("src.core.Game")
+local Renderer = require("src.render.Renderer")
+local SaveData = require("src.core.SaveData")
+
+-- ------------------------------------------------------------- the default
+
+T.eq(SaveData.newGame().options.uiLayout, "centered",
+ "a new game starts centered, not docked")
+
+-- ------------------------------------------------------ reading the option
+
+-- Only the explicit "dynamic" switches docking on. Everything else means
+-- centered, which is what makes this safe for a save written before the
+-- option existed: the key is simply absent and the player keeps the layout
+-- they already had.
+T.eq(Game.dynamicUI({ options = { uiLayout = "dynamic" } }), true,
+ "DYNAMIC turns edge docking on")
+T.eq(Game.dynamicUI({ options = { uiLayout = "centered" } }), false,
+ "CENTERED leaves it off")
+T.eq(Game.dynamicUI({ options = {} }), false,
+ "a save from before the option existed is centered")
+T.eq(Game.dynamicUI({}), false, "a save with no options at all is centered")
+T.eq(Game.dynamicUI(nil), false, "and no save at all is centered")
+
+-- ------------------------------------------------------------- the gate
+
+local function anchorsAfter(opts)
+ Renderer.uiAnchors = nil
+ Renderer.uiAnchorHold = opts.hold or false
+ Renderer.uiCentered = opts.centered or false
+ -- the dialogue box's own declaration (TextBox:draw)
+ Renderer:setUIAnchor(0, 96, 160, 48, "bottom")
+ -- and the START menu's (Menu:draw, anchor "topright")
+ Renderer:setUIAnchor(80, 0, 80, 88, "topright")
+ local n = #(Renderer.uiAnchors or {})
+ Renderer.uiAnchors, Renderer.uiAnchorHold, Renderer.uiCentered =
+ nil, false, false
+ return n
+end
+
+T.eq(anchorsAfter({ centered = true }), 0,
+ "CENTERED: neither the dialogue box nor the START menu leaves the canvas")
+T.eq(anchorsAfter({ centered = false }), 2,
+ "DYNAMIC: both dock to the window edge")
+
+-- the battle hold is unchanged by any of this -- a battle still keeps its own
+-- prompts inside its screen even with DYNAMIC on (see battle_fixed_menu_scale)
+T.eq(anchorsAfter({ centered = false, hold = true }), 0,
+ "a battle still holds the anchors while DYNAMIC is on")
+T.eq(anchorsAfter({ centered = true, hold = true }), 0, "and with it off")
+
+-- ------------------------------------------------- the scale half of it
+
+-- CENTERED is a FIXED letterbox, so the UI must not follow the survey zoom
+-- either: the box that stopped moving must not start resizing instead.
+local g = love.graphics
+local realDims, realPixelDims = g.getDimensions, g.getPixelDimensions
+g.getDimensions = function() return 640, 576 end
+g.getPixelDimensions = function() return 640, 576 end
+T.eq(Renderer:fitScale(), 4, "the fixture window fits the classic surface at 4x")
+
+local Zoom = require("src.render.Zoom")
+local function scaleAt(offset, centered)
+ local oldOff, oldActive, oldCentered =
+ Zoom.offset, Renderer.worldActive, Renderer.uiCentered
+ -- worldActive true: a live overworld pass, the one case DYNAMIC steps down
+ Zoom.offset, Renderer.worldActive, Renderer.uiCentered = offset, true, centered
+ local s = Renderer:uiScale()
+ Zoom.offset, Renderer.worldActive, Renderer.uiCentered =
+ oldOff, oldActive, oldCentered
+ return s
+end
+
+T.eq(scaleAt(0, true), 4, "CENTERED at rest is the fit scale")
+T.eq(scaleAt(-2, true), 4, "CENTERED zoomed out is STILL the fit scale")
+T.eq(scaleAt(0, false), 4, "DYNAMIC at rest matches it")
+T.eq(scaleAt(-2, false), 2, "DYNAMIC zoomed out steps the UI down, as before")
+
+g.getDimensions, g.getPixelDimensions = realDims, realPixelDims
+
+-- ------------------------------------------------------------- the row
+
+-- fixture data, not Data:load(): this tier runs ROM-free in CI, so a real
+-- load has no data/generated/ to read and takes the suite down with it
+local OptionsMenu = require("src.ui.OptionsMenu")
+local Data = T.fixtures.load()
+require("src.render.Font").load(Data)
+
+local game = { data = Data, save = SaveData.newGame(),
+ stack = { states = {}, push = function() end,
+ pop = function() end, top = function() end } }
+local menu = OptionsMenu.new(game)
+local row
+for _, r in ipairs(menu.rows) do
+ if r.id == "uiLayout" then row = r end
+end
+T.check(row ~= nil, "OPTIONS carries a UI LAYOUT row")
+T.eq(row.value(game), "CENTERED", "and it opens on CENTERED")
+row.step(game, 1)
+T.eq(game.save.options.uiLayout, "dynamic", "stepping it turns docking on")
+T.eq(row.value(game), "DYNAMIC", "and the row says so")
+row.step(game, 1)
+T.eq(game.save.options.uiLayout, "centered", "stepping again returns to it")
+
+T.finish("ui layout option")
diff --git a/tests/input_hold_test.lua b/tests/input_hold_test.lua
index 9ec07552..c8a716aa 100644
--- a/tests/input_hold_test.lua
+++ b/tests/input_hold_test.lua
@@ -67,6 +67,21 @@ Input:joystickpressed(nil, 1)
Input:step()
check(Input:isDown("a"), "raw joystick primary button presses A")
+-- A pad SDL already maps sends both event pairs for one physical button,
+-- and its raw indices are per-driver (iOS puts the D-pad on 7..10), so the
+-- raw fallback must stand down for it (#620).
+local mapped = { isGamepad = function() return true end }
+Input:reset()
+Input:joystickpressed(mapped, 9)
+Input:joystickhat(mapped, 1, "l")
+Input:joystickaxis(mapped, 1, -0.9)
+Input:step()
+check(not Input:isDown("select"), "mapped pad ignores raw button indices")
+check(not Input:isDown("left"), "mapped pad ignores raw hat and axis")
+Input:gamepadpressed(mapped, "dpleft")
+Input:step()
+check(Input:isDown("left"), "mapped pad still routes through gamepad events")
+
-- The launcher has a separate virtual cursor, so prove generic joystick
-- events reach its left-stick and D-pad state too.
local RomImporter = require("src.import.RomImporter")
@@ -90,6 +105,9 @@ check(not importer._padDir.dpright,
"raw joystick hat release clears the launcher cursor")
importer:joystickpressed(nil, 1)
check(clicked, "raw joystick primary button clicks the launcher cursor")
+clicked = false
+importer:joystickpressed(mapped, 1)
+check(not clicked, "mapped pad does not double-click the launcher cursor")
-- Drivers that only inject pressQueue still get a one-step hold.
Input:reset()
diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua
index f679d277..8100a907 100644
--- a/tests/mod_graphics_tests.lua
+++ b/tests/mod_graphics_tests.lua
@@ -621,7 +621,9 @@ check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE,
check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON,
"RED++ passes zone colors through like GBC")
-- issue #84: CELADON_DINER shares LOBBY block 29 (table top) with
--- CELADON_MART_ROOF (#52); both need the $37->$5a BROWN alias
+-- CELADON_MART_ROOF (#52); both need the $37->$5a BROWN alias.
+-- Issue #689: blocks 45 and 49 also form tables with tile $37 on their
+-- flat surfaces; CELADON_DINER uses all three.
do
local aliases = PaletteFX.TILE_ALIASES
local roof = aliases and aliases.CELADON_MART_ROOF
@@ -630,11 +632,20 @@ do
"CELADON_MART_ROOF and CELADON_DINER both have TILE_ALIASES")
check(diner == roof,
"diner reuses the same lobby table-top alias as the mart roof")
+ check(#diner == 3, "three LOBBY table blocks have the tile alias")
local al = diner and diner[1]
check(al and al.block == 29 and al.tile == 0x37 and al.alias == 0x5a
and al.group == 5 and al.cells[5] and al.cells[6]
and al.cells[9] and al.cells[10],
"lobby table-top alias remaps block 29 cells 5/6/9/10")
+ al = diner and diner[2]
+ check(al and al.block == 45 and al.tile == 0x37 and al.alias == 0x5a
+ and al.group == 5 and al.cells[13] and al.cells[14],
+ "lobby table-top alias remaps block 45 cells 13/14")
+ al = diner and diner[3]
+ check(al and al.block == 49 and al.tile == 0x37 and al.alias == 0x5a
+ and al.group == 5 and al.cells[1] and al.cells[2],
+ "lobby table-top alias remaps block 49 cells 1/2")
end
-- issue #128: RED++'s gbc pack is Red-derived; Blue must keep ROM LOGO1
-- (and the Blue-only SLOTS* rows) so the title ribbon is blue, not red
@@ -673,7 +684,7 @@ for _, id in ipairs({ "doublecircle", "spiralin", "circle", "spiralout",
"hstripes", "shrink", "vstripes", "split" }) do
check(transitions:get(id) ~= nil, "the engine registers wipe " .. id)
end
-check(transitions:get("warp_fade").frames == 12,
+check(transitions:get("warp_fade").frames == 32,
"the warp fade registers as a transitions record")
check(transitions:get("white_flash").frames == 7,
"the white flash registers as a transitions record")
@@ -689,8 +700,23 @@ end
local retimed = { transitions = { warp_fade = { kind = "fade", frames = 30 } } }
check(Transition.new({ data = retimed }).frames == 30,
"a patched warp_fade record changes the fade length")
-check(Transition.new({ data = { transitions = {} } }).frames == 12,
- "an unregistered id falls back to the built-in 12 frames")
+check(Transition.new({ data = { transitions = {} } }).frames == 32,
+ "an unregistered id falls back to the built-in 32 frames")
+
+-- issue #607: the warp fade is GBFadeOutToBlack's four palette writes, each
+-- held eight frames (home/fade.asm:43-60) -- the map is untouched through the
+-- first hold and solid black through the last, with nothing tweened between.
+do
+ local staircase = Transition.new({ data = { transitions = {} } })
+ staircase.phase = "out"
+ local want = { { 0, 0 }, { 7, 0 }, { 8, 1 / 3 }, { 15, 1 / 3 },
+ { 16, 2 / 3 }, { 23, 2 / 3 }, { 24, 1 }, { 31, 1 } }
+ for _, w in ipairs(want) do
+ staircase.t = w[1]
+ check(staircase:alpha() == w[2],
+ ("the warp fade holds its shade at frame %d"):format(w[1]))
+ end
+end
-- issue #121: with the survey-zoom world pass active, the warp fade must
-- darken the full window composite (via Renderer.worldFadeAlpha), not just
@@ -711,7 +737,9 @@ do
Renderer:init()
local fade = Transition.new({ renderer = Renderer, stack = { pop = noop } })
- fade.t = 6 -- mid fade-out (12 frames)
+ -- GBFadeOutToBlack steps every eight frames, so frame 16 of the 32 frame
+ -- fade is its third palette write: two thirds of the way to black (#607)
+ fade.t = 16
fade.phase = "out"
Renderer:beginFrame(true)
@@ -719,7 +747,7 @@ do
Renderer:endWorldPass()
check(Renderer.worldActive == true, "world pass stays marked active until endFrame")
fade:draw()
- check(Renderer.worldFadeAlpha == 0.5,
+ check(Renderer.worldFadeAlpha == 2 / 3,
"warp fade hands mid-out alpha to the world composite overlay")
check(#rects == 0,
"warp fade does not paint the 160x144 UI letterbox while the world pass is up")
@@ -730,9 +758,10 @@ do
local fadeRect
for _, r in ipairs(rects) do
-- endFrame's letterbox clear is also a full-window black fill (a == 1);
- -- the warp overlay is the half-alpha one Transition requested
+ -- the warp overlay is the two-thirds-alpha one Transition requested
if r.mode == "fill" and r.x == 0 and r.y == 0
- and r.w == 640 and r.h == 576 and r.r == 0 and r.a == 0.5 then
+ and r.w == 640 and r.h == 576 and r.r == 0
+ and math.abs(r.a - 2 / 3) < 1e-6 then
fadeRect = r
end
end
@@ -742,7 +771,7 @@ do
-- without a world pass (opaque UI states), keep the classic UI rect
Renderer:beginFrame(false)
- fade.t = 6
+ fade.t = 16
fade.phase = "out"
rects = {}
fade:draw()
diff --git a/tests/mod_link_tests.lua b/tests/mod_link_tests.lua
index b5f8d75d..bb55a825 100644
--- a/tests/mod_link_tests.lua
+++ b/tests/mod_link_tests.lua
@@ -212,6 +212,49 @@ eq(Handshake.mods(overhaulGame)[1].affectsLink, true, "and rides the hello")
eq(Handshake.hello(tweakGame, "trade").linkModified, true,
"the hello carries the flag a v1 peer is judged against")
+-- #501: a declared translation is invisible to the wire, so online play
+-- lets an English install meet a Spanish one
+local languageGame = loadMods({
+ ["mods/espanol/manifest.json"] =
+ '{"id":"espanol","name":"espanol","version":"1.0.0","entry":"main.lua",' ..
+ '"language":true,"category":"LANGUAGE"}',
+ ["mods/espanol/main.lua"] = [[
+return function(mod)
+ mod.content.strings:override("But, it failed!", "Pero fallo!")
+end
+]],
+})
+eq(Handshake.mods(languageGame)[1].language, true,
+ "the translation flag rides the hello")
+eq(Handshake.mods(languageGame)[1].affectsLink, false,
+ "and a translation never claims the fingerprint")
+eq(Handshake.linkModified(languageGame), false,
+ "it leaves the link surface alone")
+eq(Handshake.onlineAllowed(languageGame), true, "so online play allows it")
+eq(Fingerprint.compute(languageGame.data, Handshake.mods(languageGame)),
+ Fingerprint.compute(languageGame.data, {}),
+ "and two peers reading different languages hash the same")
+eq(Handshake.onlineAllowed(tweakGame), false,
+ "a content mod that touches a species still forces vanilla")
+eq(Handshake.onlineAllowed(bareGame), true, "vanilla still goes online")
+
+-- the flag is a claim, not a pass: a mod that writes gameplay is not a
+-- translation however its manifest describes itself
+local fakeLanguageGame = loadMods({
+ ["mods/faux/manifest.json"] =
+ '{"id":"faux","name":"faux","version":"1.0.0","entry":"main.lua","language":true}',
+ ["mods/faux/main.lua"] = [[
+return function(mod)
+ mod.content.strings:override("But, it failed!", "It worked!")
+ mod.content.pokemon:patch("PIKA", { baseStats = { attack = 200 } })
+end
+]],
+})
+eq(Handshake.onlineAllowed(fakeLanguageGame), false,
+ "a self-declared translation that patches a species is still blocked")
+eq(#Handshake.onlineBlockers(fakeLanguageGame), 1,
+ "and the mod manager restart prompt names it")
+
-- ------- builtin records are private per dataset
-- two independent loads must not share record tables: an edit through one
diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua
index eac4d93c..49169fb4 100644
--- a/tests/mod_ui_tests.lua
+++ b/tests/mod_ui_tests.lua
@@ -779,8 +779,9 @@ check(oak.demoSpecies == "NIDORINO" and oak.nameLen == 7,
-- ------- intro.oak_speech.build
local vanillaSteps = OakSpeech.defaultSteps(oak)
-check(#vanillaSteps == 9, "vanilla speech has nine steps")
-check(vanillaSteps[1].id == "oak_welcome" and vanillaSteps[9].id == "shrink",
+check(#vanillaSteps == 11, "vanilla speech has eleven steps")
+check(vanillaSteps[1].id == "oak_welcome"
+ and vanillaSteps[#vanillaSteps].id == "shrink",
"vanilla speech anchors start and end")
hooks:wrap("intro.oak_speech.build", function(nextFn, steps, speech)
@@ -799,7 +800,7 @@ hooks:removeOwner("fixture")
hooks:wrap("intro.oak_speech.build", function() return 42 end, 0, "bad")
built = oak:buildSteps()
-check(#built == 9 and built[1].id == "oak_welcome",
+check(#built == #vanillaSteps and built[1].id == "oak_welcome",
"a non-table intro.oak_speech.build result degrades to vanilla")
check(logged("intro.oak_speech.build returned"),
"the intro build degrade is logged")
@@ -1084,6 +1085,36 @@ ms:deleteProfile(easy)
check(ms:findProfile("HARD") == nil
and ms:optionsTable().activeProfile == nil, "delete clears the profile")
+-- #593: a profile carries mod options and the per-version save slot, and
+-- round-trips through the .g1rmodlist export
+local ModProfile = require("src.mods.ModProfile")
+ms:setOption("okmod", "hardcore", true)
+ms:saveCurrentAs()
+mgame.stack:top().onDone("SHARE")
+mgame.stack:pop()
+local shared = ms:findProfile("SHARE")
+check(shared.options.okmod.hardcore == true,
+ "a profile snapshots per-mod options, not just the enable set")
+ms:setOption("okmod", "hardcore", false)
+ms:applyProfile(shared)
+check(loader.modOptions.okmod.hardcore == true,
+ "applying a profile restores its mod options")
+local wire = ModProfile.encode(shared)
+local back = ModProfile.decode(wire)
+check(back and back.name == "SHARE" and back.options.okmod.hardcore == true,
+ "a .g1rmodlist body round-trips through the data-only parser")
+check(ModProfile.decode("return {}") == nil, "a non-modlist file is refused")
+check(#ModProfile.missingIds({ enabled = { ghost = true } }, ms.byId) == 1,
+ "a profile naming an uninstalled mod reports it missing")
+local seedOpts = { modProfiles = {} }
+ModProfile.ensureFirst(seedOpts, ms.status.available, {})
+check(#seedOpts.modProfiles == 1 and seedOpts.modProfiles[1].name == "PROFILE 1"
+ and seedOpts.modProfilesSeeded == true,
+ "the pre-profiles setup migrates into PROFILE 1 once")
+seedOpts.modProfiles = {}
+ModProfile.ensureFirst(seedOpts, ms.status.available, {})
+check(#seedOpts.modProfiles == 0, "seeding never runs twice")
+
-- permissions rows
local permy = manifest("permy", { permissions = { "network" } })
local msP = ManagerState.new(managerGame(fakeLoader({ permy })))
diff --git a/tests/mod_world_tests.lua b/tests/mod_world_tests.lua
index 6d8063a8..60aefb97 100644
--- a/tests/mod_world_tests.lua
+++ b/tests/mod_world_tests.lua
@@ -1011,6 +1011,10 @@ do
"the boot path seeded field.palettes")
check(Data.field.playerSprites.walk == "SPRITE_RED",
"the boot path seeded field.playerSprites")
+ check(Data.field.playerSprites.surf == "SPRITE_SEEL",
+ "the surf sprite defaults to the Seel")
+ check(Data.field.playerSprites.surfPikachu == "SPRITE_SURFING_PIKACHU",
+ "the surfing-Pikachu sprite defaults to SPRITE_SURFING_PIKACHU (RFC 0001; Yellow ride)")
check(Data.field.badgeGates.ROUTE_22_GATE.passedFlag == "PASSED_ROUTE22_GATE",
"the boot path filled the gaps in a stamped key")
check(Data.constants.world.stepFrames == 16,
diff --git a/tests/parity_F.lua b/tests/parity_F.lua
index e90d1084..d514ebf6 100644
--- a/tests/parity_F.lua
+++ b/tests/parity_F.lua
@@ -34,6 +34,16 @@ Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
require("src.render.Font").load(Data)
+-- spy on Sound.play so the starter-received jingle beat is observable
+-- without real audio (parity_C does the same for its arrival SFX)
+local Sound = require("src.core.Sound")
+local realSoundPlay = Sound.play
+local played = {}
+Sound.play = function(data, name)
+ played[#played + 1] = name
+ return realSoundPlay(data, name)
+end
+
-- pumps a script coroutine to completion; pressFn returns the Input.pressed
-- table for this frame (default: mash A through text/ask/naming)
local function runScript(script, pressFn)
@@ -64,6 +74,14 @@ check(Flags.get(Game.save, "EVENT_GOT_STARTER"), "starter flag set")
eq(Game.save.inventory.POKE_BALL, nil, "no POKe BALLs yet right after picking a starter")
check(Game.save.party[1] and Game.save.party[1].species == "BULBASAUR",
"starter joined the party")
+-- #668: OaksLabReceivedMonText carries sound_get_key_item; the jingle
+-- must fire as the starter is handed over (once for the player's mon,
+-- once for the rival's counter-pick)
+local jingles = 0
+for _, name in ipairs(played) do
+ if name == "Get_Key_Item" then jingles = jingles + 1 end
+end
+eq(jingles, 2, "starter + rival counter-pick both play the Get_Key_Item jingle (#668)")
-- A-mash accepts the nickname prompt and fills NamingScreen with A's
check(Game.save.party[1].nickname == "AAAAAAAAAA",
"starter nickname prompt accepted (AskName / #137)")
@@ -182,4 +200,6 @@ do
Game.save = realSave
end
+Sound.play = realSoundPlay
+
S.finish()
diff --git a/tests/parity_H.lua b/tests/parity_H.lua
index 2b6a5cbe..5d519013 100644
--- a/tests/parity_H.lua
+++ b/tests/parity_H.lua
@@ -40,9 +40,12 @@ end
check(sf.SEAFOAM_ISLANDS_B3F and #sf.SEAFOAM_ISLANDS_B3F.holes == 2,
"SEAFOAM_ISLANDS_B3F still has its own 2 holes (B3F->B4F, unrelated to this change)")
--- data/scripts/seafoam.lua no longer force-shows the B2F boulders on entry
+-- data/scripts/seafoam.lua no longer force-shows the B2F boulders on entry.
+-- The map DOES own an onStep now (the player's hole falls, #599), so the
+-- assertion is about onEnter specifically, not about the map table.
local seafoamScripts = require("data.scripts.seafoam")
-check(seafoamScripts.SEAFOAM_ISLANDS_B2F == nil,
+check(seafoamScripts.SEAFOAM_ISLANDS_B2F == nil
+ or seafoamScripts.SEAFOAM_ISLANDS_B2F.onEnter == nil,
"data/scripts/seafoam.lua no longer hardcodes a SEAFOAM_ISLANDS_B2F onEnter hook")
-- === (2) functional 1F -> B1F -> B2F -> B3F -> B4F cascade ===
diff --git a/tests/parity_battle_intro_chrome.lua b/tests/parity_battle_intro_chrome.lua
index af1b12f8..37532314 100644
--- a/tests/parity_battle_intro_chrome.lua
+++ b/tests/parity_battle_intro_chrome.lua
@@ -19,6 +19,7 @@ local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local Sound = require("src.core.Sound")
local Music = require("src.core.Music")
+local Timing = require("src.core.Timing")
-- Silence audio: BattleState reaches both modules through require() at the
-- call site, so patching the fields here is what the battle ends up calling.
@@ -40,8 +41,11 @@ local function makeGame(party)
function stack:push(state) self.states[#self.states + 1] = state end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
+ -- isDown as well as wasPressed: battle text collapses PrintLetterDelay
+ -- while A or B is held, and the typing path reads it every frame
return { data = Data, save = save, stack = stack,
- input = { wasPressed = function(_, b) return press[b] == true end } }
+ input = { wasPressed = function(_, b) return press[b] == true end,
+ isDown = function(_, b) return press[b] == true end } }
end
-- One fixed step with A held. updateQueue only reads the button once a page
@@ -128,6 +132,12 @@ end
check(prompted, "a typed-out intro page raises the prompt flag (blinking arrow)")
eq(wild.introBalls, true, "the ball row is still up while the arrow blinks")
+-- PromptText writes the arrow and then runs ProtectedDelay3 before
+-- ManualTextScroll starts watching the joypad (home/text.asm:213-217), so the
+-- page ignores the button for TEXT_PRE_ADVANCE frames. The loop above breaks
+-- on the frame the arrow goes up, which is inside that hold.
+for _ = 1, Timing.TEXT_PRE_ADVANCE do press.a = false wild:update(1 / 60) end
+
-- press A: ClearSprites + both ClearScreenAreas, then the enemy HUD
step(wild)
eq(wild.msgPrompt, nil, "the prompt flag clears on the A press")
@@ -196,6 +206,26 @@ check(foeDone ~= nil and sentFrame ~= nil and foeDone < sentFrame,
"the slide finishes BEFORE TrainerSentOutText (core.asm:1308-1310)")
eq(tr.showEnemyTrainer, false, "only then is the trainer pic taken down")
+-- ...and the mon is not standing in that slot yet. The pic walks off at
+-- core.asm:1308-1310 but AnimateSendingOutMon does not run until :1421-1434,
+-- so the slot is empty for the whole of TrainerSentOutText -- the mon is
+-- still in its ball. It used to pop in full-size the instant the trainer
+-- left and sit there through the text, so the grow-in played over a mon that
+-- had already arrived; the mid-battle replacement always held it back.
+check(tr.enemySendingOut, "the foe's mon stays in its ball while announced")
+eq(tr:growInScale(tr.enemy), nil, "and nothing is growing into the slot yet")
+
+local firstGrowScale
+for _ = 1, 400 do
+ step(tr)
+ if tr.growIn and tr.growIn.battler == tr.enemy then
+ firstGrowScale = tr:growInScale(tr.enemy)
+ break
+ end
+end
+eq(firstGrowScale, 0, "the send-out opens on the ball beat, not a finished pic")
+eq(tr.enemySendingOut, false, "which is when the slot is handed over")
+
-- and the window never reopens: drive the rest of the intro out
for _ = 1, 400 do
step(tr)
diff --git a/tests/parity_field_move_layering.lua b/tests/parity_field_move_layering.lua
index 6d39964e..d5c3bbf3 100644
--- a/tests/parity_field_move_layering.lua
+++ b/tests/parity_field_move_layering.lua
@@ -160,7 +160,11 @@ ow.player.surfing = false
-- =====================================================================
-- .flash: `xor a / ld [wMapPalOffset], a` is undone before PrintText in
-- the asm, but the map is not on screen then -- the party menu is -- so
--- the lit cave may only appear once the blink hands the screen back
+-- the lit cave may only appear once the blink hands the screen back.
+-- The lighting itself happens as the message closes the menu, ahead of
+-- the blink like the asm: in ADVANCED that call rebakes every resident
+-- map, and behind the blink's completion that cost was spent with a
+-- blank white frame as the newest thing on screen (#610).
-- =====================================================================
Game.save.flashLit = false
Game.save.party = { mkMon("PIKACHU", "FLASH") }
@@ -174,7 +178,9 @@ eq(backdrop(), pmFlash, "the party menu is the backdrop of _FlashLightsAreaText"
eq(ow.dark, true, "the tunnel is still dark while the message is up")
drainOne()
check(isBlink(Game.stack:top()), "the blink follows the FLASH message")
-eq(ow.dark, true, "the tunnel is still dark when the blink starts")
+-- lit before the blink is pushed, so no rebuild can run while the white
+-- frame is the newest one presented (#610); the blink hides it either way
+eq(ow.dark, false, "the tunnel is lit by the time the blink starts")
settle(ow)
eq(Game.stack:top(), ow, "FLASH ends on the map")
eq(ow.dark, false, "the tunnel is lit once the blink hands the map back")
diff --git a/tests/parity_flash_blink_bug610.lua b/tests/parity_flash_blink_bug610.lua
new file mode 100644
index 00000000..161d52ae
--- /dev/null
+++ b/tests/parity_flash_blink_bug610.lua
@@ -0,0 +1,90 @@
+-- Parity test: nothing expensive may ride the FLASH white-out's completion
+-- callback (#610). Transition.WhiteFlash is opaque and paints the whole
+-- 160x144 solid white, so whatever runs from its onDone runs with a blank
+-- white frame as the newest thing the player has been shown. Lighting the
+-- cave is exactly that kind of work: in the ADVANCED colour mode
+-- OverworldState:setDark drops every resident map and rebakes the tileset
+-- atlas pixel by pixel (#383), which on a phone is seconds of frozen white
+-- and reads as a lockup. engine/menus/start_sub_menus.asm .flash clears
+-- wMapPalOffset before PrintText and calls GBPalWhiteOutWithDelay3 last of
+-- all, so the port lights the cave as the message closes the menu and
+-- leaves the blink a plain 7-frame blink with no work attached.
+package.path = "./?.lua;./?/init.lua;" .. package.path
+if not _G.love then _G.love = require("tests.love_stub") end
+local Data = require("src.core.Data")
+if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
+local S = require("tests.harness").suite("parity flash blink bug610")
+local check, eq = S.check, S.eq
+
+require("src.render.Font").load(Data)
+local Game = require("src.core.Game")
+local Input = require("src.core.Input")
+local StateStack = require("src.core.StateStack")
+local Renderer = require("src.render.Renderer")
+local SaveData = require("src.core.SaveData")
+local Pokemon = require("src.pokemon.Pokemon")
+local PartyMenu = require("src.ui.PartyMenu")
+local OW = require("src.world.OverworldController")
+
+Game.data = Data
+Game.input = Input; Input:init()
+Game.renderer = Renderer; Renderer:init()
+Game.stack = StateStack; StateStack:init()
+Game.save = SaveData.newGame()
+
+local function frame(btns)
+ Input.pressed = {}
+ for _, b in ipairs(btns or {}) do Input.pressed[b] = true; Input.state[b] = true end
+ StateStack:update(1 / 60)
+ for _, b in ipairs(btns or {}) do Input.state[b] = false end
+end
+local function popAll() while Game.stack:top() do Game.stack:pop() end end
+local function mkMon(species, ...)
+ local m = Pokemon.new(Data, species, 20)
+ m.moves = {}
+ for _, id in ipairs({ ... }) do m.moves[#m.moves + 1] = { id = id, pp = 15 } end
+ return m
+end
+-- dismiss exactly one box, leaving whatever it pushed on top
+local function drainOne()
+ local box = Game.stack:top()
+ local guard = 0
+ while Game.stack:top() == box and guard < 400 do
+ guard = guard + 1
+ frame({ "a" })
+ end
+end
+
+Game.save.flashLit = false
+Game.save.party = { mkMon("PIKACHU", "FLASH") }
+Game.save.inventory = { BOULDERBADGE = true }
+popAll()
+Game.stack:push(OW, "ROCK_TUNNEL_1F", 15, 4, "down")
+local ow = Game.stack:top()
+eq(ow.dark, true, "ROCK_TUNNEL_1F loads dark before FLASH")
+
+local pm = PartyMenu.new(Game)
+Game.stack:push(pm)
+frame({ "a" }) -- open the field-move submenu on PIKACHU
+for _ = 2, 3 do frame({ "down" }) end
+frame({ "a" }) -- FLASH
+drainOne() -- dismiss _FlashLightsAreaText
+
+local blink = Game.stack:top()
+check(blink ~= nil and blink.isOpaque == true and type(blink.frames) == "number",
+ "the blink follows the FLASH message")
+-- the invariant this test exists for: the blink carries no work
+eq(blink.onDone, nil, "the FLASH blink has no completion callback (#610)")
+eq(ow.dark, false, "the cave is already lit when the blink starts")
+
+local guard = 0
+while Game.stack:top() ~= ow and guard < 240 do
+ guard = guard + 1
+ frame({})
+end
+eq(Game.stack:top(), ow, "FLASH ends on the map")
+eq(ow.dark, false, "the cave stays lit after the blink")
+eq(Game.save.flashLit, true, "FLASH is recorded on the save")
+
+popAll()
+S.finish()
diff --git a/tests/parity_seafoam_holes.lua b/tests/parity_seafoam_holes.lua
new file mode 100644
index 00000000..8de99096
--- /dev/null
+++ b/tests/parity_seafoam_holes.lua
@@ -0,0 +1,71 @@
+-- Parity test: the Seafoam Islands floor holes drop the PLAYER a floor,
+-- not just the boulders (#599).
+--
+-- scripts/SeafoamIslands1F.asm / B1F.asm / B2F.asm / B3F.asm each set
+-- wDungeonWarpDestinationMap and call IsPlayerOnDungeonWarp with their
+-- SeafoamNHolesCoords list; data/maps/special_warps.asm DungeonWarpList /
+-- DungeonWarpData turn (destination map, wCoordIndex) into the landing
+-- cell. CAVERN $22 is walkable, so without an onStep the player just
+-- stood on the hole.
+--
+-- Self-contained; run via `luajit tests/parity_seafoam_holes.lua`.
+package.path = "./?.lua;./?/init.lua;" .. package.path
+if not _G.love then _G.love = require("tests.love_stub") end
+local S = require("tests.harness").suite("parity seafoam holes")
+local check, eq = S.check, S.eq
+
+local M = require("data.scripts.seafoam")
+
+local function owRecording()
+ local warps = {}
+ return {
+ player = { facing = "up" },
+ startWarpTo = function(_, mapId, x, y, facing)
+ warps[#warps + 1] = { mapId = mapId, x = x, y = y, facing = facing }
+ end,
+ }, warps
+end
+
+-- hole cell -> map, landing cell (DungeonWarpData)
+local CASES = {
+ { "SEAFOAM_ISLANDS_1F", 17, 6, "SEAFOAM_ISLANDS_B1F", 18, 7 },
+ { "SEAFOAM_ISLANDS_1F", 24, 6, "SEAFOAM_ISLANDS_B1F", 23, 7 },
+ { "SEAFOAM_ISLANDS_B1F", 18, 6, "SEAFOAM_ISLANDS_B2F", 19, 7 },
+ { "SEAFOAM_ISLANDS_B1F", 23, 6, "SEAFOAM_ISLANDS_B2F", 22, 7 },
+ { "SEAFOAM_ISLANDS_B2F", 19, 6, "SEAFOAM_ISLANDS_B3F", 18, 7 },
+ { "SEAFOAM_ISLANDS_B2F", 22, 6, "SEAFOAM_ISLANDS_B3F", 19, 7 },
+ { "SEAFOAM_ISLANDS_B3F", 3, 16, "SEAFOAM_ISLANDS_B4F", 4, 14 },
+ { "SEAFOAM_ISLANDS_B3F", 6, 16, "SEAFOAM_ISLANDS_B4F", 5, 14 },
+}
+
+for _, c in ipairs(CASES) do
+ local hooks = M[c[1]]
+ check(hooks ~= nil and hooks.onStep ~= nil, c[1] .. " has an onStep hole trigger")
+ local ow, warps = owRecording()
+ check(hooks.onStep({}, ow, c[2], c[3]), c[1] .. " consumes the hole step")
+ eq(#warps, 1, c[1] .. " fires exactly one fall")
+ eq(warps[1].mapId, c[4], c[1] .. " falls to " .. c[4])
+ eq(warps[1].x, c[5], c[1] .. " lands at x=" .. c[5])
+ eq(warps[1].y, c[6], c[1] .. " lands at y=" .. c[6])
+ eq(warps[1].facing, "up", "facing is preserved across the fall")
+end
+
+-- a neighboring floor cell is ignored
+do
+ local ow, warps = owRecording()
+ eq(M.SEAFOAM_ISLANDS_1F.onStep({}, ow, 18, 6), false, "a floor cell is ignored")
+ eq(#warps, 0, "no fall fires off the hole")
+end
+
+-- the hole tiles stay walkable CAVERN holes, and are not map warp events
+local Data = require("src.core.Data")
+if not (Data.maps and Data.maps.SEAFOAM_ISLANDS_1F) then Data:load() end
+local MapLoader = require("src.world.MapLoader")
+for _, c in ipairs(CASES) do
+ local map = MapLoader.load(Data, c[1])
+ check(map:isWalkableCell(c[2], c[3]), c[1] .. " hole tile is walkable")
+ eq(map:warpPadOrHoleAt(c[2], c[3]), "hole", c[1] .. " collision tile is CAVERN $22")
+ check(map:warpAtCell(c[2], c[3]) == nil, c[1] .. " hole is not a map warp event")
+end
+
+S.finish()
diff --git a/tests/parity_static.lua b/tests/parity_static.lua
index 79bd4532..fa6f7f46 100644
--- a/tests/parity_static.lua
+++ b/tests/parity_static.lua
@@ -265,6 +265,47 @@ Flags.set(Game.save, "EVENT_BEAT_ROUTE12_SNORLAX")
result = ItemEffects.use(Data, Game.save, "POKE_FLUTE", nil, nil, nil, owAdjacent)
eq(result, "flute_field", "an already-beaten Snorlax doesn't wake again")
+-- === 9) a beaten Snorlax never stands in the road again: the routes'
+-- onEnter reconciles the object toggle from EVENT_BEAT_ROUTEnn_SNORLAX,
+-- so a save that lost the toggle (mod toggle, old build) is repaired
+-- on entry instead of sealing the route -- the flute refuses to wake
+-- a beaten Snorlax, which is what left #585 stuck ===
+local OverworldState = require("src.world.OverworldController")
+local function objOf(mapId, objName)
+ for _, obj in ipairs(Data.maps[mapId].objects or {}) do
+ if obj.name == objName then return obj end
+ end
+end
+
+for _, route in ipairs({
+ { "ROUTE_12", "ROUTE12_SNORLAX", "EVENT_BEAT_ROUTE12_SNORLAX" },
+ { "ROUTE_16", "ROUTE16_SNORLAX", "EVENT_BEAT_ROUTE16_SNORLAX" },
+}) do
+ local mapId, objName, beatFlag = route[1], route[2], route[3]
+ local enter = mapScripts.get(mapId).onEnter
+ check(type(enter) == "function", mapId .. " has an onEnter hook")
+ enter = type(enter) == "function" and enter or function() end
+
+ -- unbeaten: entering must not remove the sleeper
+ local save = SaveData.newGame()
+ enter({ save = save }, nil)
+ eq(save.objectToggles and save.objectToggles[mapId]
+ and save.objectToggles[mapId][objName], nil,
+ objName .. " is left alone while it has not been beaten")
+ check(OverworldState.objectVisible(save, mapId, objOf(mapId, objName)),
+ objName .. " still spawns before the flute wakes it")
+
+ -- beaten but still toggled visible (the #585 save state): repaired
+ save.flags[beatFlag] = true
+ save.objectToggles = save.objectToggles or {}
+ save.objectToggles[mapId] = { [objName] = true }
+ enter({ save = save }, nil)
+ eq(save.objectToggles[mapId][objName], false,
+ objName .. " is hidden again once " .. beatFlag .. " is set")
+ check(not OverworldState.objectVisible(save, mapId, objOf(mapId, objName)),
+ objName .. " no longer spawns on a beaten route")
+end
+
-- restore the real commands for later suites
Commands.show_text = origShow
Commands.start_battle = origStart
diff --git a/tests/parity_surfing_pikachu_sprite.lua b/tests/parity_surfing_pikachu_sprite.lua
new file mode 100644
index 00000000..540f27e9
--- /dev/null
+++ b/tests/parity_surfing_pikachu_sprite.lua
@@ -0,0 +1,128 @@
+-- Parity port: Yellow's IsSurfingPikachuInParty (home/map_objects.asm).
+-- When the party mon that knows SURF is a Pikachu, the player's surf sprite
+-- swaps from the default (the Seel) to a Pikachu overworld sheet. This
+-- mirrors vanilla Yellow, which repoints wSpritePlayerStatePtr at the
+-- surfing-Pikachu sheet during the ride.
+--
+-- Covers the engine change that adds field.playerSprites.surfPikachu,
+-- Player.surfPikachuSprite, OverworldState:syncSurfingPikachu, and the
+-- pose() sprite-pick switch. No sprite bytes are read -- the assertions
+-- compare the chosen SpriteRenderer's backing def, so the test is ROM-free
+-- against the fixture dataset and runs in CI without an import.
+--
+-- Self-contained; run via:
+-- luajit tests/parity_surfing_pikachu_sprite.lua
+package.path = "./?.lua;./?/init.lua;" .. package.path
+if not _G.love then _G.love = require("tests.love_stub") end
+
+local Data = require("src.core.Data")
+if not (Data.maps and Data.maps.CINNABAR_ISLAND) then Data:load() end
+
+local Game = require("src.core.Game")
+local Input = require("src.core.Input")
+local Renderer = require("src.render.Renderer")
+local SaveData = require("src.core.SaveData")
+local StateStack = require("src.core.StateStack")
+local OW = require("src.world.OverworldController")
+local S = require("tests.harness").suite("parity surfing-pikachu sprite")
+local check, eq = S.check, S.eq
+
+-- field default: the new key is seeded alongside the existing surf sprite,
+-- so a mod-free boot gets the Pikachu overworld sheet by default
+check(Data.field.playerSprites.surfPikachu == "SPRITE_SURFING_PIKACHU",
+ "field.playerSprites.surfPikachu defaults to SPRITE_SURFING_PIKACHU (RFC 0001; Yellow ride)")
+check(Data.field.playerSprites.surf == "SPRITE_SEEL",
+ "the default surf sprite is still the Seel (no behavior change)")
+
+Game.data = Data
+Game.input = Input; Input:init()
+Game.renderer = Renderer; Renderer:init()
+Game.stack = StateStack
+StateStack:init()
+Game.overworld = OW
+
+local function mkMon(species, ...)
+ local moves = {}
+ for _, id in ipairs({ ... }) do
+ table.insert(moves, { id = id, pp = 10, ppUp = 0 })
+ end
+ return {
+ species = species, level = 30, hp = 50, maxHp = 50,
+ status = 0, moves = moves,
+ }
+end
+
+local function freshOw(party)
+ Game.save = SaveData.newGame()
+ Game.save.party = party
+ Game.save.inventory = { SOULBADGE = true }
+ while Game.stack:top() do Game.stack:pop() end
+ Game.stack:push(OW, "CINNABAR_ISLAND", 19, 8, "right")
+ local ow = Game.stack:top()
+ -- the mount itself goes through trySurf (TextBox + whiteFlash + step),
+ -- so arm the surf state directly and let syncSurfingPikachu derive
+ -- the sprite pick -- the unit under test.
+ ow.player.surfing = true
+ ow:syncSurfingPikachu()
+ -- the real SPRITE_SURFING_PIKACHU art only exists after an Yellow
+ -- import + the RFC 0001 extractor path; inject a sentinel surf sprite
+ -- so pose()'s selection rule is testable in the fixture dataset.
+ ow.player.surfPikachuSprite = { def = { id = "SPRITE_TEST_SURF_PIKA" } }
+ return ow
+end
+
+-- the sprite def backing a Player:surfSprite / surfPikachuSprite, so the
+-- assertion can compare identities without drawing
+local function surfSpriteId(player)
+ local sprite, _px, _py = player:pose()
+ return sprite and sprite.def and sprite.def.id or nil
+end
+
+-- -------------------------------------------------- Pikachu knows SURF
+do
+ local ow = freshOw({ mkMon("PIKACHU", "SURF"), mkMon("SQUIRTLE") })
+ check(ow.player.surfing, "player is now surfing")
+ check(ow.player.surfingPikachu == true,
+ "surfingPikachu set when a Pikachu knows SURF")
+ eq(surfSpriteId(ow.player), "SPRITE_TEST_SURF_PIKA",
+ "pose() draws the surf-pikachu sprite, not the Seel (when set)")
+
+ -- dismount flips it off again: set surfing false and re-sync, the way
+ -- the dismount paths in OverworldController do
+ ow.player.surfing = false
+ ow:syncSurfingPikachu()
+ check(ow.player.surfingPikachu == false,
+ "syncSurfingPikachu clears the flag when dismounted")
+end
+
+-- -------------------------------------------------- no Pikachu, no swap
+do
+ local ow = freshOw({ mkMon("SQUIRTLE", "SURF") })
+ check(ow.player.surfing, "player is surfing")
+ check(ow.player.surfingPikachu == false,
+ "no Pikachu in the SURF-mon: surfingPikachu stays false")
+ eq(surfSpriteId(ow.player), "SPRITE_SEEL",
+ "pose() keeps the default Seel surf sprite")
+end
+
+-- -------------------------------------------------- SURF-knower is the
+-- Pikachu's species, not a slot position: a Pikachu without SURF behind a
+-- Squirtle that has SURF must NOT trigger the swap
+do
+ local ow = freshOw({ mkMon("SQUIRTLE", "SURF"), mkMon("PIKACHU", "THUNDER_SHOCK") })
+ check(ow.player.surfingPikachu == false,
+ "Pikachu present but not the SURF-mon: no swap")
+ eq(surfSpriteId(ow.player), "SPRITE_SEEL",
+ "pose() keeps the Seel when the SURF-mon is not a Pikachu")
+end
+
+-- -------------------------------------------------- not surfing: no swap
+do
+ local ow = freshOw({ mkMon("PIKACHU", "SURF") })
+ ow.player.surfing = false
+ ow:syncSurfingPikachu()
+ check(ow.player.surfingPikachu == false,
+ "syncSurfingPikachu is a no-op swap when not surfing")
+end
+
+S.finish()
\ No newline at end of file
diff --git a/tests/parity_trainer_victory_text.lua b/tests/parity_trainer_victory_text.lua
index 47a24a8e..45cf8446 100644
--- a/tests/parity_trainer_victory_text.lua
+++ b/tests/parity_trainer_victory_text.lua
@@ -36,8 +36,11 @@ local function makeGame(party)
function stack:push(state) self.states[#self.states + 1] = state end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
+ -- isDown as well as wasPressed: battle text collapses PrintLetterDelay
+ -- while A or B is held, and the typing path reads it every frame
return { data = Data, save = save, stack = stack,
- input = { wasPressed = function(_, b) return press[b] == true end } }
+ input = { wasPressed = function(_, b) return press[b] == true end,
+ isDown = function(_, b) return press[b] == true end } }
end
-- A held: updateQueue only reads the button once a page is typed out, so an
diff --git a/tests/parity_viridian_school_blackboard_bug503.lua b/tests/parity_viridian_school_blackboard_bug503.lua
index c2b131c1..c4182037 100644
--- a/tests/parity_viridian_school_blackboard_bug503.lua
+++ b/tests/parity_viridian_school_blackboard_bug503.lua
@@ -22,7 +22,6 @@ if not pcall(Font.encode, "A") then Font.load(Data) end
require("data.scripts.init")
local MapScripts = require("src.script.MapScripts")
local TextBox = require("src.render.TextBox")
-local Menu = require("src.ui.Menu")
local S = require("tests.harness").suite("parity Viridian School blackboard/notebook (#503)")
local check, eq = S.check, S.eq
@@ -69,7 +68,7 @@ for _, cell in ipairs({ { 4, 0 }, { 3, 1 }, { 4, 4 }, { 3, 3 }, { 2, 0 } }) do
("(%d,%d) is not one of the readables"):format(cell[1], cell[2]))
end
--- === the blackboard: intro -> prompt -> 6-item menu (5 statuses + QUIT) ===
+-- === the blackboard: intro -> held prompt with a two-column list over it ===
stack = {}
eq(hooks.onInteract(game, ow, 3, 0), true, "the blackboard at (3,0) is claimed")
local intro = stack[#stack]
@@ -83,16 +82,29 @@ local prompt = stack[#stack]
check(getmetatable(prompt) == TextBox
and pages(prompt):find("heading", 1, true) ~= nil,
"the intro leads into the which-heading prompt")
-prompt.onDone()
+-- #591: _ViridianSchoolBlackboardText2 ends in `done`, so .blackboardLoop
+-- leaves it on screen and runs HandleMenuInput under it; the port used to
+-- make the player dismiss it and then hid it while the list was up
+check(prompt.onDone == nil and prompt.stay ~= nil,
+ "the prompt is held open instead of waiting for A and popping")
+prompt.stay.onShown()
-local menu = stack[#stack]
-check(getmetatable(menu) == Menu, "the prompt opens the status menu")
-eq(#menu.items, 6, "five statuses plus QUIT")
+local board = stack[#stack]
+check(getmetatable(board) ~= TextBox and type(board.draw) == "function",
+ "the held prompt opens the headings list over itself")
+eq(stack[#stack - 1], prompt, "the list sits on the still-visible prompt box")
local labels = {}
-for i, item in ipairs(menu.items) do labels[i] = (item.label or ""):gsub("^%s+", "") end
+for i, label in ipairs(board.labels) do labels[i] = label:gsub("^%s+", "") end
eq(table.concat(labels, "/"), "SLP/PSN/PAR/BRN/FRZ/QUIT",
"the statuses read SLP/PSN/PAR/BRN/FRZ, QUIT last")
-eq(menu.items[6].onSelect, nil, "QUIT has no onSelect; Menu's own B/pop closes it")
+-- two columns: StatusAilmentText1 at hlcoord 1,2 and StatusAilmentText2 at
+-- hlcoord 6,2, RIGHT keeping the row and adding wMenuItemOffset 3
+board.col, board.row = 1, 1
+eq(board:selection(), 1, "top of the left column is SLP")
+board.col, board.row = 2, 1
+eq(board:selection(), 4, "RIGHT keeps the row and lands on BRN (offset 3)")
+board.col, board.row = 2, 3
+eq(board:selection(), 6, "bottom of the right column is QUIT")
local STATUS_KEYS = {
"_ViridianBlackboardSleepText", "_ViridianBlackboardPoisonText",
@@ -100,8 +112,9 @@ local STATUS_KEYS = {
"_ViridianBlackboardFrozenText",
}
for i, key in ipairs(STATUS_KEYS) do
- stack = {}
- menu.items[i].onSelect()
+ stack = { prompt, board } -- picking pops the list AND the box under it
+ board.onPick(i)
+ eq(#stack, 1, labels[i] .. " clears the list and the prompt it sat on")
local blurb = stack[#stack]
check(getmetatable(blurb) == TextBox, labels[i] .. " prints a text box")
local want = Data.text[key]:match("^[^\n\011\012]+")
@@ -110,13 +123,32 @@ for i, key in ipairs(STATUS_KEYS) do
check(type(blurb.onDone) == "function",
labels[i] .. " returns to the prompt instead of dropping out (loops)")
blurb.onDone()
- check(getmetatable(stack[#stack]) == TextBox,
- "the prompt comes back after " .. labels[i])
- stack[#stack].onDone()
- check(getmetatable(stack[#stack]) == Menu,
- "the menu comes back after " .. labels[i] .. " (loop, not exit)")
+ local back = stack[#stack]
+ check(getmetatable(back) == TextBox and back.stay ~= nil,
+ "the held prompt comes back after " .. labels[i])
+ back.stay.onShown()
+ check(stack[#stack].labels ~= nil,
+ "the list comes back after " .. labels[i] .. " (loop, not exit)")
end
+-- #591: wCurrentMenuItem / wMenuItemOffset are zeroed once above
+-- .blackboardLoop, so `jp .blackboardLoop` after a blurb comes back with the
+-- cursor on the row and column that was just picked (read FRZ, land on FRZ)
+stack = { prompt, board }
+board.col, board.row = 2, 2
+eq(board:selection(), 5, "right column, middle row is FRZ")
+board.onPick(5)
+stack[#stack].onDone()
+stack[#stack].stay.onShown()
+eq(stack[#stack], board, "the same headings list comes back, not a fresh one")
+eq(board.col, 2, "the column survives the blurb (wMenuItemOffset is not recleared)")
+eq(board.row, 2, "the row survives the blurb (wCurrentMenuItem is not recleared)")
+
+-- QUIT and B share .exitBlackboard: both close the list and the prompt
+stack = { prompt, board }
+board.onQuit()
+eq(#stack, 0, "QUIT/B closes the headings list and the prompt box together")
+
-- === the notebook: five pages, three yes/no turns, then the girl catches you ==
stack = {}
eq(hooks.onInteract(game, ow, 3, 4), true, "the notebook at (3,4) is claimed")
diff --git a/tests/parity_wardens_house_bug535.lua b/tests/parity_wardens_house_bug535.lua
index aafda88c..7ab69ee5 100644
--- a/tests/parity_wardens_house_bug535.lua
+++ b/tests/parity_wardens_house_bug535.lua
@@ -36,13 +36,22 @@ local script = story.WARDENS_HOUSE.talk.TEXT_WARDENSHOUSE_WARDEN
-- instrument show_text the way parity_gift_atomicity.lua does, to record
-- exactly which text ids actually printed
local shown = {}
+-- forward EVERY argument: the 4th is extraOpts, which is how Commands.ask
+-- hands down its `choice` callback. A wrapper that stops at `subs` silently
+-- turns every ask in the script back into a plain show_text -- no YES/NO box,
+-- and ctx.lastCheck left holding whatever the previous check_* put there.
local origShow = Commands.show_text
-Commands.show_text = function(ctx, textId, subs)
+Commands.show_text = function(ctx, textId, ...)
shown[#shown + 1] = textId
- return origShow(ctx, textId, subs)
+ return origShow(ctx, textId, ...)
end
-local function runScript()
+-- `button` drives the whole conversation: both A and B page a text box, and
+-- on the YES/NO box A takes the cursor's default (YES) while B snaps to NO
+-- and answers false (ChoiceBox:update, .choseSecondMenuItem). So holding A
+-- runs the yes branch and holding B runs the no branch, with no reaching
+-- into the choice box from the test.
+local function runScript(button)
shown = {}
StateStack:init()
local ow = { map = { id = "WARDENS_HOUSE", def = { label = "WardensHouse" } },
@@ -53,7 +62,7 @@ local function runScript()
local guard = 0
while r:isRunning() and guard < 3000 do
guard = guard + 1
- Input.pressed = { a = true }
+ Input.pressed = { [button or "a"] = true }
StateStack:update(1 / 60)
r:update()
end
@@ -85,13 +94,34 @@ check(runScript(), "a third talk completes")
eq(table.concat(shown, ","), "_WardensHouseWardenHM04ExplanationText",
"the explanation text repeats on every later talk, not just the first")
--- === 3) unrelated path is unchanged: no GOLD TEETH, no flag yet ===
+-- === 3) no GOLD TEETH yet: the gibberish question, then a YES/NO, then the
+-- warden's answer -- Gibberish2 on yes, Gibberish3 on no (#645).
+-- The port used to stop dead after the question. ===
Game.save = SaveData.newGame()
-check(runScript(), "empty-handed talk completes")
-eq(table.concat(shown, ","), "_WardensHouseWardenGibberish1Text",
- "without the GOLD TEETH the Warden's gibberish line is unchanged")
+check(runScript("a"), "empty-handed talk completes on yes")
+eq(table.concat(shown, ","),
+ "_WardensHouseWardenGibberish1Text,_WardensHouseWardenGibberish2Text",
+ "answering YES gets the warden's reply, not silence (#645)")
check(not Flags.get(Game.save, "EVENT_GOT_HM04"), "no HM04 yet")
+Game.save = SaveData.newGame()
+check(runScript("b"), "empty-handed talk completes on no")
+eq(table.concat(shown, ","),
+ "_WardensHouseWardenGibberish1Text,_WardensHouseWardenGibberish3Text",
+ "and answering NO gets the other reply (#645)")
+
+-- the question is asked, not just printed: `ask` is what puts the YES/NO box
+-- up, so a future edit that downgrades it back to show_text fails here
+local askRow
+for _, row in ipairs(script) do
+ if row[2] == "_WardensHouseWardenGibberish1Text" then askRow = row[1] end
+end
+eq(askRow, "ask", "the gibberish line is asked with a YES/NO, not just shown")
+
+-- neither answer touches the teeth trade
+check(not Flags.get(Game.save, "EVENT_GAVE_GOLD_TEETH"),
+ "and neither answer hands over teeth the player does not have")
+
Commands.show_text = origShow
S.finish()
diff --git a/tests/parity_yellow_bills_pikachu.lua b/tests/parity_yellow_bills_pikachu.lua
index 79dc7b36..c0437c36 100644
--- a/tests/parity_yellow_bills_pikachu.lua
+++ b/tests/parity_yellow_bills_pikachu.lua
@@ -108,5 +108,82 @@ PikachuFollower.onBillExitedMachine(yellowGame, ow)
check(ow.emote and ow.emote.bubble == 2 and npc.facing == "left",
"Pikachu reacts when Bill comes back out")
+-- BillsHousePikachuWatchPlayer (scripts/BillsHouse_2.asm:133-156) is the
+-- other side of BillsHouseScript2: it only runs while Pikachu still follows
+-- the player, and TryApplyPikachuMovementData keys each table on
+-- GetPikachuFacingDirectionAndReturnToE, which is Pikachu's position
+-- relative to the player rather than its facing byte, so WatchPlayer1 wants
+-- Pikachu above the player and WatchPlayer2 wants it level and east (#455).
+local function placePikachu(cx, cy)
+ npc.cellX, npc.cellY, npc.facing = cx, cy, "down"
+end
+
+ow.pikachuBillsScene = nil
+moves = {}
+placePikachu(ow.player.cellX, ow.player.cellY - 1)
+PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow)
+check(#moves == 1 and moves[1].dir == "left" and moves[1].tiles == 1,
+ "a Pikachu standing above the player steps aside for Bill's detour")
+moves[1].onDone()
+check(#moves == 2 and moves[2].dir == "down" and moves[2].tiles == 1,
+ "the watch route drops clear of Bill's detour")
+moves[2].onDone()
+check(npc.facing == "right", "PIKAMOVEMENT_LOOK_RIGHT watches the player")
+
+moves = {}
+placePikachu(ow.player.cellX + 1, ow.player.cellY)
+PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow)
+check(#moves == 1 and moves[1].dir == "up" and moves[1].tiles == 1,
+ "level with and east of the player takes the longer WatchPlayer2 route")
+moves[1].onDone()
+check(#moves == 2 and moves[2].dir == "left" and moves[2].tiles == 2,
+ "WatchPlayer2 crosses two cells west")
+moves[2].onDone()
+check(#moves == 3 and moves[3].dir == "down" and moves[3].tiles == 1,
+ "WatchPlayer2 drops back level with the player")
+moves[3].onDone()
+check(npc.facing == "right", "WatchPlayer2 ends watching the player too")
+
+-- below the player is SPRITE_FACING_DOWN and west of it is
+-- SPRITE_FACING_LEFT, and neither call asks for those
+moves = {}
+placePikachu(ow.player.cellX, ow.player.cellY + 1)
+PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow)
+check(#moves == 0, "a Pikachu trailing from the south has no watch route")
+
+placePikachu(ow.player.cellX - 1, ow.player.cellY)
+PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow)
+check(#moves == 0, "nor does one already standing west of the player")
+
+-- ApplyPikachuMovementData_ finishes a table before the second
+-- TryApplyPikachuMovementData reads the geometry again, but WatchPlayer1
+-- lands on (playerX - 1, playerY), so WatchPlayer2 can never chain onto it
+moves = {}
+placePikachu(ow.player.cellX, ow.player.cellY - 1)
+PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow)
+moves[1].onDone()
+moves[2].onDone()
+placePikachu(ow.player.cellX - 1, ow.player.cellY)
+moves = {}
+PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow)
+check(#moves == 0, "the two tables stay mutually exclusive by geometry")
+
+-- the parked Pikachu of the confused beat is the not-following side of
+-- CheckPikachuFollowingPlayer, so it never watches
+ow.pikachuBillsScene = true
+placePikachu(ow.player.cellX, ow.player.cellY - 1)
+PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow)
+check(#moves == 0, "the parked Pikachu of the confused beat stays put")
+
+-- BillsHouseScript0 skips the whole entry beat for a statused starter
+-- (CheckPikachuStatusCondition, scripts/BillsHouse.asm:45-46)
+ow.pikachuBillsScene = nil
+moves = {}
+yellowGame.save.party = { { species = "PIKACHU", hp = 12, status = "PAR" } }
+PikachuFollower.onBillsHouseEnter(yellowGame, ow)
+check(not ow.pikachuBillsScene and #moves == 0,
+ "a statused starter keeps following instead of walking over to Bill")
+yellowGame.save.party = nil
+
GameVersion.set("red")
S.finish()
diff --git a/tests/parity_yellow_old_man.lua b/tests/parity_yellow_old_man.lua
new file mode 100644
index 00000000..54de6b44
--- /dev/null
+++ b/tests/parity_yellow_old_man.lua
@@ -0,0 +1,288 @@
+-- Parity (#617): Yellow's Viridian old man is the OLD_MAN2 at (18,9),
+-- not the Red/Blue OLD_MAN at (17,5), and his dialog has no yes/no
+-- choice -- the apology speech runs the RATTATA demo battle straight
+-- away, the post-battle line is the losing-my-touch text, and he walks
+-- off and hides.
+--
+-- Oracle: pokeyellow scripts/OaksLab.asm (OaksLabOakGivesPokedexScript:
+-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN_2),
+-- scripts/ViridianCity.asm (ViridianCityCheckWaitingOldMan,
+-- ViridianCityOldMan2Text, ...InitialCatchTrainingScript,
+-- ...PostInitialCatchTraining) and scripts/ViridianCity_2.asm
+-- (ViridianCityPrintOldManText). The Red/Blue "Are you in a hurry?"
+-- script was running against Yellow's text: YES printed the TimeIsMoney
+-- alias (_ViridianCityOldManLosingMyTouchText) and NO ran the demo --
+-- every talk, forever.
+--
+-- Self-contained: `luajit tests/parity_yellow_old_man.lua`; also globbed
+-- by tests/run_tests.lua.
+package.path = "./?.lua;./?/init.lua;" .. package.path
+if not _G.love then _G.love = require("tests.love_stub") end
+
+local Data = require("src.core.Data")
+if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
+local GameVersion = require("src.core.GameVersion")
+local SaveData = require("src.core.SaveData")
+local ScriptRunner = require("src.script.ScriptRunner")
+local TextBox = require("src.render.TextBox")
+local BattleState = require("src.battle.BattleState")
+local Pokemon = require("src.pokemon.Pokemon")
+
+local S = require("tests.harness").suite("parity Yellow old man (#617)")
+local check, eq = S.check, S.eq
+
+local oldVersion = GameVersion.get()
+
+local MAP = "VIRIDIAN_CITY"
+local SLEEPER = "VIRIDIANCITY_OLD_MAN_SLEEPY"
+local WALKER = "VIRIDIANCITY_OLD_MAN"
+local OLD_MAN2 = "VIRIDIANCITY_OLD_MAN2"
+local DONE_FLAG = "EVENT_COMPLETED_CATCH_TRAINING"
+
+-- The Yellow wiring must be attached before anything else caches the
+-- map-script registry: data.scripts.init branches on GameVersion at
+-- load, so flip it first (this file owns its own process when run
+-- standalone). Under tests/run_tests.lua the registry is already
+-- cached with the Red wiring, so attach the Yellow modules directly
+-- afterwards -- attachBase merges per TEXT constant and replaces hooks,
+-- which is a no-op on a fresh process and the fix on a shared one.
+GameVersion.set("yellow")
+local mapScripts = require("data.scripts.init")
+local MapScripts = require("src.script.MapScripts")
+MapScripts.attachBase(MAP,
+ require("data.scripts.yellow_viridian_old_man").VIRIDIAN_CITY)
+MapScripts.attachBase("OAKS_LAB",
+ require("data.scripts.oaks_lab_yellow"))
+local oldManMod = require("data.scripts.yellow_viridian_old_man")
+
+-- ------------------------------------------------------- the demo species
+-- The catch demo is a RATTATA in Yellow (SetupBattle sets wCurOpponent
+-- = RATTATA) but the Yellow manifest inherited Red's WEEDLE; the runtime
+-- override in Data:applyVersionedFieldData repairs old caches. Kept
+-- active until the end of this file so the demo-battle assertions below
+-- run against the Yellow value; restored before S.finish() like
+-- parity_yellow_trades does for its trades table.
+local originalOldManBattle = Data.field.oldManBattle
+ or { species = "WEEDLE", level = 5 } -- the fixture carries no oldManBattle
+local originalTrades = Data.field.trades
+eq(originalOldManBattle.species, "WEEDLE",
+ "Red/Blue's old man still demos a Weedle")
+GameVersion.set("yellow")
+Data:applyVersionedFieldData()
+eq(Data.field.oldManBattle.species, "RATTATA",
+ "Yellow's old man demos a Rattata (#617)")
+
+local manifestFile = assert(io.open("tools/rom_manifest_yellow.json", "r"))
+local yellowManifest = manifestFile:read("*a")
+manifestFile:close()
+check(yellowManifest:find('"species": "RATTATA"', 1, true) ~= nil,
+ "the Yellow manifest stamps RATTATA for fresh imports")
+local redManifestFile = assert(io.open("tools/rom_manifest.json", "r"))
+local redManifest = redManifestFile:read("*a")
+redManifestFile:close()
+check(redManifest:find('"species": "WEEDLE"', 1, true) ~= nil,
+ "and the Red/Blue manifest keeps WEEDLE")
+
+-- ------------------------------------------------------- the Pokedex swap
+-- OaksLabOakGivesPokedexScript shows TOGGLE_OLD_MAN_2 (the tutorial old
+-- man standing on the sleeper's cell), never the Red/Blue walker
+local oaksRows = mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")
+check(type(oaksRows) == "table",
+ "the Yellow OaksLab Oak talk resolves to rows")
+local sawSleepHide, sawOldMan2Show, sawOldManShow = false, false, false
+for _, row in ipairs(oaksRows or {}) do
+ if row[1] == "hide_object" and row[3] == SLEEPER then sawSleepHide = true end
+ if row[1] == "show_object" and row[3] == OLD_MAN2 then sawOldMan2Show = true end
+ if row[1] == "show_object" and row[3] == WALKER then sawOldManShow = true end
+end
+check(sawSleepHide, "the Pokédex hand-over hides the lying old man")
+check(sawOldMan2Show, "it shows OLD_MAN2 on the sleeper's cell")
+check(not sawOldManShow, "it never shows the Red/Blue OLD_MAN (#617)")
+
+-- both Yellow gamblers default hidden (toggle OFF), like pokeyellow
+-- data/maps/toggleable_objects.asm. OLD_MAN2 only exists in a Yellow
+-- import -- a Red-imported checkout carries just OLD_MAN -- so the
+-- dataset checks tolerate its absence and the Yellow manifest carries
+-- the OLD_MAN2 default instead.
+local walkerDef, oldMan2Def
+if Data.maps[MAP] then
+ for _, o in ipairs(Data.maps[MAP].objects or {}) do
+ if o.name == WALKER then walkerDef = o end
+ if o.name == OLD_MAN2 then oldMan2Def = o end
+ end
+end
+check(walkerDef == nil or walkerDef.hidden == true,
+ "VIRIDIANCITY_OLD_MAN defaults hidden in Yellow")
+check(oldMan2Def == nil or oldMan2Def.hidden == true,
+ "VIRIDIANCITY_OLD_MAN2 defaults hidden in Yellow")
+local om2Name = yellowManifest:find('"name": "VIRIDIANCITY_OLD_MAN2"', 1, true)
+local om2Hidden = om2Name and yellowManifest:sub(
+ math.max(1, om2Name - 40), om2Name):find('"hidden": true', 1, true)
+check(om2Hidden ~= nil,
+ "the Yellow manifest ships OLD_MAN2 with the toggle OFF")
+
+-- ------------------------------------------------------- script registry
+local talk = mapScripts.talkScript(MAP, "TEXT_VIRIDIANCITY_OLD_MAN2")
+check(type(talk) == "function",
+ "TEXT_VIRIDIANCITY_OLD_MAN2 resolves to the Yellow handler")
+check(type(mapScripts.talkScript(MAP, "TEXT_VIRIDIANCITY_OLD_MAN")) == "table",
+ "the Red/Blue OLD_MAN talk is still registered (unreachable in Yellow)")
+local hooks = mapScripts.get(MAP)
+check(hooks and type(hooks.onEnter) == "function",
+ "VIRIDIAN_CITY.onEnter is the Yellow swap")
+check(hooks and type(hooks.onStep) == "function",
+ "VIRIDIAN_CITY.onStep chains the gym lock and sleeper gate")
+check(oldManMod.VIRIDIAN_CITY and oldManMod.VIRIDIAN_CITY.talk
+ and oldManMod.VIRIDIAN_CITY.talk.TEXT_VIRIDIANCITY_OLD_MAN2 == talk,
+ "the handler is the module's own, not a leftover merge")
+
+-- ------------------------------------------------------- completed branch
+do
+ local pushed = {}
+ local game = {
+ data = Data,
+ save = SaveData.newGame(),
+ stack = { push = function(_, s) pushed[#pushed + 1] = s end },
+ }
+ game.save.flags.EVENT_COMPLETED_CATCH_TRAINING = true
+ local done = false
+ talk(game, nil, {}, function() done = true end)
+ eq(#pushed, 1, "a second talk only prints one box")
+ eq(getmetatable(pushed[1]), TextBox, "the losing-my-touch line, in a box")
+ pushed[1].onDone()
+ check(done, "closing it hands input back")
+end
+
+-- ------------------------------- the initial tutorial, end to end
+-- Needs real species in the dataset (the fixture carries only FIX_*);
+-- the engine's old-man demo machinery itself is parity_J's territory.
+if Data.pokemon.RATTATA and Data.pokemon.PIKACHU then
+do
+ require("src.render.Font").load(Data)
+ local pushed = {}
+ local save = SaveData.newGame()
+ save.party = { Pokemon.new(Data, "PIKACHU", 12) }
+ local moves = {}
+ local man = { def = { index = 8, name = OLD_MAN2 } }
+ local ow = {
+ map = { id = MAP, def = { label = "ViridianCity" } },
+ npcs = { man }, entities = { man },
+ player = { cellX = 19, cellY = 9, facing = "left" },
+ scriptMove = function(_, _, dir, _, cb) moves[#moves + 1] = dir; cb() end,
+ npcByIndex = function(_, i) if i == 8 then return man end end,
+ }
+ local game = {
+ data = Data,
+ save = save,
+ stack = { push = function(_, s) pushed[#pushed + 1] = s end },
+ }
+ local runner = ScriptRunner.new(game, ow)
+ ow.runner = runner
+ local done = false
+ talk(game, ow, man, function() done = true end)
+
+ eq(#pushed, 1, "the initial talk opens the apology speech")
+ eq(getmetatable(pushed[1]), TextBox, "in a text box")
+ pushed[1].onDone() -- A: the apology closes, the demo battle starts
+
+ eq(#pushed, 2, "the demo battle starts with no choice in between")
+ local battle = pushed[2]
+ check(battle and battle.demo, "it is the old-man demo battle")
+ eq(battle and battle.enemy and battle.enemy.mon.species, "RATTATA",
+ "the demo is a RATTATA in Yellow (#617)")
+ eq(save.flags[DONE_FLAG], nil, "the flag is still clear mid-demo")
+ battle.onFinish() -- the battle ends, the post-battle text prints
+
+ eq(save.flags[DONE_FLAG], true, "EVENT_COMPLETED_CATCH_TRAINING is set")
+ eq(#pushed, 3, "the losing-my-touch line follows the demo")
+ pushed[3].onDone() -- A: the old man walks off
+
+ eq(#moves, 6, "with the player on (19,9) he walks down 6 tiles")
+ check(moves[1] == "down" and moves[6] == "down",
+ "all six steps are the ViridianCityOldManMovementData2 walk")
+ eq(save.objectToggles[MAP] and save.objectToggles[MAP][OLD_MAN2], false,
+ "TOGGLE_OLD_MAN_2 hides once the walk finishes")
+ check(done, "and the talk hands input back")
+end
+
+-- ---------------------------------- side talk: player not on (19,9) cell
+do
+ local pushed = {}
+ local save = SaveData.newGame()
+ save.party = { Pokemon.new(Data, "PIKACHU", 12) }
+ local moves = {}
+ local man = { def = { index = 8, name = OLD_MAN2 } }
+ local pika = { def = { index = 99, name = "PIKACHU_FOLLOWER" },
+ pikachuFollower = true }
+ local ow = {
+ map = { id = MAP, def = { label = "ViridianCity" } },
+ npcs = { man, pika }, entities = { man, pika },
+ player = { cellX = 18, cellY = 8, facing = "down" },
+ scriptMove = function(_, _, dir, _, cb) moves[#moves + 1] = dir; cb() end,
+ npcByIndex = function(_, i) if i == 8 then return man elseif i == 99 then return pika end end,
+ }
+ local game = {
+ data = Data,
+ save = save,
+ stack = { push = function(_, s) pushed[#pushed + 1] = s end },
+ }
+ local runner = ScriptRunner.new(game, ow)
+ ow.runner = runner
+ talk(game, ow, man, function() end)
+ pushed[1].onDone()
+ pushed[2].onFinish()
+ pushed[3].onDone()
+ eq(moves[1], "right", "Pikachu steps aside first (ViridianCityMovePikachu)")
+ eq(moves[2], "right", "then the old man turns right one tile")
+ eq(#moves, 2, "and no more")
+end
+else
+ check(true, "fixture dataset: demo-battle flow skipped (no RATTATA)")
+end
+
+-- --------------------------------------------------------- the (19,9) step
+do
+ local pushed = {}
+ local save = SaveData.newGame()
+ local man = { def = { index = 8, name = OLD_MAN2 } }
+ local ow = {
+ map = { id = MAP, def = { label = "ViridianCity" } },
+ npcs = { man }, entities = { man },
+ player = { cellX = 19, cellY = 9, facing = "down" },
+ scriptMove = function(_, _, _, _, cb) cb() end,
+ npcByIndex = function() end,
+ }
+ local game = {
+ data = Data,
+ save = save,
+ stack = { push = function(_, s) pushed[#pushed + 1] = s end },
+ }
+ local runner = ScriptRunner.new(game, ow)
+ ow.runner = runner
+
+ check(not hooks.onStep(game, ow, 5, 5),
+ "off the trigger cell the step passes through")
+ check(hooks.onStep(game, ow, 19, 9),
+ "pre-Pokedex the sleeper gate owns (19,9)")
+ eq(#pushed, 1, "with the sleepy text box")
+ check(save.flags[DONE_FLAG] ~= true, "the tutorial is not running")
+
+ save.flags.EVENT_GOT_POKEDEX = true
+ check(hooks.onStep(game, ow, 19, 9),
+ "with the Pokedex, (19,9) starts the tutorial")
+ eq(man.facing, "right", "the old man faces the player")
+ eq(ow.player.facing, "left", "and the player turns to face him")
+ eq(#pushed, 2, "the apology box is up")
+ check(save.flags[DONE_FLAG] ~= true,
+ "no flag until the demo battle actually runs")
+
+ save.flags.EVENT_COMPLETED_CATCH_TRAINING = true
+ check(not hooks.onStep(game, ow, 19, 9),
+ "once the tutorial is done the cell is quiet again")
+end
+
+Data.field.trades = originalTrades
+Data.field.oldManBattle = originalOldManBattle
+GameVersion.set(oldVersion)
+
+S.finish()
diff --git a/tests/run_tests.lua b/tests/run_tests.lua
index a907ddef..255b8454 100644
--- a/tests/run_tests.lua
+++ b/tests/run_tests.lua
@@ -1699,8 +1699,10 @@ do
local SafeArea = require("src.core.SafeArea")
local cfg = TC.normalizeConfig(nil)
eq(cfg.enabled, true, "touchControls default enabled")
- check(cfg.positions == nil, "touchControls default positions nil")
+ check(cfg.layouts.portrait.positions == nil, "touchControls default positions nil")
+ eq(cfg.layouts.landscape.scale, 1, "touchControls default scale 1")
+ -- pre-#633 file: one positions table seeds both orientations, as copies
cfg = TC.normalizeConfig({
enabled = false,
positions = {
@@ -1711,11 +1713,15 @@ do
},
})
eq(cfg.enabled, false, "normalizeConfig keeps enabled=false")
- eq(cfg.positions.dpad.x, 1, "normalizeConfig clamps x high")
- eq(cfg.positions.dpad.y, 0, "normalizeConfig clamps y low")
- eq(cfg.positions.a.x, 0.5, "normalizeConfig keeps a")
- check(cfg.positions.junk == nil, "normalizeConfig drops unknown controls")
- check(cfg.positions.b == nil, "normalizeConfig drops non-numeric")
+ local pcfg, lcfg = cfg.layouts.portrait, cfg.layouts.landscape
+ eq(pcfg.positions.dpad.x, 1, "normalizeConfig clamps x high")
+ eq(pcfg.positions.dpad.y, 0, "normalizeConfig clamps y low")
+ eq(pcfg.positions.a.x, 0.5, "normalizeConfig keeps a")
+ check(pcfg.positions.junk == nil, "normalizeConfig drops unknown controls")
+ check(pcfg.positions.b == nil, "normalizeConfig drops non-numeric")
+ eq(lcfg.positions.dpad.x, 1, "legacy positions seed landscape too")
+ check(pcfg.positions ~= lcfg.positions,
+ "orientations never share one positions table (#633)")
local L = TC.defaultLayout(400, 800)
check(L.dpad.cx < 200, "default d-pad on left half")
diff --git a/tests/save_editor_wheel_bug595_test.lua b/tests/save_editor_wheel_bug595_test.lua
new file mode 100644
index 00000000..9c0b2485
--- /dev/null
+++ b/tests/save_editor_wheel_bug595_test.lua
@@ -0,0 +1,64 @@
+-- #595: the save editor's ADD ITEM list was reachable only by typing.
+-- Kit.scroll is the shared wheel handler behind the fix; it takes a notch
+-- only when the pointer is inside the list body, consumes it so two stacked
+-- lists cannot both move, clamps to the last page, and honours the modal
+-- shield. Standalone like the other panel suites (see the header of
+-- tests/run_save_editor_tests.lua):
+-- luajit tests/save_editor_wheel_bug595_test.lua
+
+package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
+ .. ";./tools/save-editor/panels/?.lua"
+
+local love_stub = require("tests.love_stub")
+love = love or love_stub
+
+local T = require("tests.harness")
+local check, eq = T.check, T.eq
+
+local Kit = require("Kit")
+
+Kit.beginFrame(50, 50, false, -1)
+eq(Kit.scroll(0, 0, 100, 100, 0, 250, 10), 3,
+ "a wheel notch scrolls the list under the pointer")
+eq(Kit.wheelY, 0, "and is consumed, so only one list moves")
+Kit.endFrame()
+
+Kit.beginFrame(50, 50, false, 1)
+eq(Kit.scroll(0, 0, 100, 100, 3, 250, 10), 0, "wheel up scrolls back")
+Kit.endFrame()
+
+Kit.beginFrame(50, 50, false, -1)
+eq(Kit.scroll(0, 0, 100, 100, 245, 250, 10), 240,
+ "the last page is the floor of the list")
+Kit.endFrame()
+
+Kit.beginFrame(500, 500, false, -1)
+eq(Kit.scroll(0, 0, 100, 100, 0, 250, 10), 0,
+ "a list the pointer is not over ignores the wheel")
+Kit.endFrame()
+
+Kit.beginFrame(50, 50, false, -1)
+eq(Kit.scroll(0, 0, 100, 100, 0, 8, 10), 0, "a list that fits cannot scroll")
+Kit.endFrame()
+
+Kit.beginFrame(50, 50, false, -1)
+Kit.blockClicks = true
+eq(Kit.scroll(0, 0, 100, 100, 0, 250, 10), 0,
+ "the modal shield stops the wheel exactly as it stops a click")
+Kit.blockClicks = false
+Kit.endFrame()
+eq(Kit.wheelY, 0, "an unclaimed notch retires with the frame")
+
+-- The wheel has to reach the Items lists without touching the map camera,
+-- which is the only thing App.wheelmoved used to drive (#595). Loading the
+-- whole editor needs data/generated/, so pin the routing at the source seam
+-- instead: App.wheelmoved must queue notches for Kit on every non-map tab.
+local f = assert(io.open("tools/save-editor/App.lua", "r"))
+local src = f:read("*a")
+f:close()
+check(src:find("wheelY = wheelY + (y or 0)", 1, true) ~= nil,
+ "App.wheelmoved queues notches for Kit on non-map tabs (#595)")
+check(src:find("Kit.beginFrame(mx, my, mouseClicked, wheelY)", 1, true) ~= nil,
+ "App.draw hands the queued notches to Kit.beginFrame (#595)")
+
+T.finish("save_editor_wheel_bug595")
diff --git a/tools/build_rom_data.py b/tools/build_rom_data.py
index 8428812b..8f0e02a4 100755
--- a/tools/build_rom_data.py
+++ b/tools/build_rom_data.py
@@ -461,6 +461,27 @@ def extract_sprites(rom, symbols, manifest, out_dir, assets_dir):
"walker": bike_frames >= 6,
}
+ # Yellow-only: the surfing-Pikachu overworld ride sheet, loaded
+ # outside SpriteSheetPointerTable (LoadSurfingPlayerSpriteGraphics2) --
+ # same extra extract as RedBikeSprite. (RFC 0001)
+ surf = manifest["sprites"].get("surfPikachu")
+ if surf and _has_symbol(symbols, surf["label"]):
+ surf_symbol = _symbol(symbols, surf["label"])
+ surf_length = surf["imageWidth"] * surf["imageHeight"] // 4
+ _write_2bpp_png(
+ rom.bytes(surf_symbol.bank, surf_symbol.address, surf_length),
+ surf["imageWidth"], surf["imageHeight"],
+ os.path.join(assets_dir, "sprites", surf["imageBase"] + ".png"),
+ transparent_color0=True)
+ surf_frames = surf["imageHeight"] // 16
+ out["SPRITE_SURFING_PIKACHU"] = {
+ "id": "SPRITE_SURFING_PIKACHU",
+ "source": f"ROM:{surf['label']}",
+ "image": f"assets/generated/sprites/{surf['imageBase']}.png",
+ "frames": surf_frames,
+ "walker": surf_frames >= 6,
+ }
+
util.write_lua(
os.path.join(out_dir, "sprites.lua"), out,
header="Source: canonical Pokemon Red ROM (overworld sprite sheets)")
diff --git a/tools/extract/sprites.py b/tools/extract/sprites.py
index 8290b07f..e6727e12 100644
--- a/tools/extract/sprites.py
+++ b/tools/extract/sprites.py
@@ -83,6 +83,23 @@ def extract(pokered, out_dir, assets_dir, sprite_order):
"walker": frames >= 6,
}
+ # Yellow-only: the surfing-Pikachu overworld ride sheet, loaded
+ # outside SpriteSheetPointerTable (LoadSurfingPlayerSpriteGraphics2) --
+ # same extra extract as RedBikeSprite. (RFC 0001)
+ surf_src = files.get("SurfingPikachuSprite")
+ if surf_src:
+ png_src = os.path.join(pokered, re.sub(r"\.2bpp$", ".png", surf_src))
+ dst = os.path.join(assets_dir, "sprites", "surfing_pikachu.png")
+ size = gfx.convert_png(png_src, dst, transparent_color0=True)
+ frames = size[1] // 16
+ out["SPRITE_SURFING_PIKACHU"] = {
+ "id": "SPRITE_SURFING_PIKACHU",
+ "source": "gfx/sprites.asm SurfingPikachuSprite (LoadSurfingPlayerSpriteGraphics2)",
+ "image": "assets/generated/sprites/surfing_pikachu.png",
+ "frames": frames,
+ "walker": frames >= 6,
+ }
+
util.write_lua(os.path.join(out_dir, "sprites.lua"), out,
header="Sources: data/sprites/sprites.asm, gfx/sprites/*.png\n"
"Frame order (walker): stand D/U/L, walk D/U/L; right = flipped left.")
diff --git a/tools/make_rom_manifest.py b/tools/make_rom_manifest.py
index 09dd5495..031a849f 100755
--- a/tools/make_rom_manifest.py
+++ b/tools/make_rom_manifest.py
@@ -280,7 +280,7 @@ def sprite_metadata(pokered, order):
bike_png = re.sub(r"\.2bpp$", ".png", bike_source)
with Image.open(os.path.join(pokered, bike_png)) as image:
bike_width, bike_height = image.size
- return {
+ out_manifest = {
"order": out,
"bike": {
"label": "RedBikeSprite",
@@ -289,6 +289,20 @@ def sprite_metadata(pokered, order):
"imageHeight": bike_height,
},
}
+ # Yellow-only: the surfing-Pikachu overworld ride sheet, parallel
+ # to the bike entry; absent in Red/Blue. (RFC 0001)
+ surf_source = files.get("SurfingPikachuSprite")
+ if surf_source:
+ surf_png = re.sub(r"\.2bpp$", ".png", surf_source)
+ with Image.open(os.path.join(pokered, surf_png)) as image:
+ surf_width, surf_height = image.size
+ out_manifest["surfPikachu"] = {
+ "label": "SurfingPikachuSprite",
+ "imageBase": "surfing_pikachu",
+ "imageWidth": surf_width,
+ "imageHeight": surf_height,
+ }
+ return out_manifest
def text_metadata(pokered):
diff --git a/tools/make_yellow_manifest.py b/tools/make_yellow_manifest.py
index f102e459..4e0e3782 100755
--- a/tools/make_yellow_manifest.py
+++ b/tools/make_yellow_manifest.py
@@ -103,6 +103,10 @@ YELLOW_EXTRA_SYMBOLS = (
"Pic_e5b7d", "Pic_e5ddd", "GFX_e6020", "Pic_e6340", "Pic_e6587",
"Pic_e67d6", "GFX_e6e6f", "GFX_e718f", "GFX_e74af", "Pic_e77cf",
"Pic_f0abf", "Pic_f0cf4",
+ # Yellow-only overworld player surf sprite, loaded outside
+ # SpriteSheetPointerTable (LoadSurfingPlayerSpriteGraphics2) --
+ # needs its own extract like RedBikeSprite. (RFC 0001)
+ "SurfingPikachuSprite",
)
# Yellow-only dialogue whose bank labels carry no leading underscore, so
diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json
index 8b94d472..d62595fa 100644
--- a/tools/rom_manifest_yellow.json
+++ b/tools/rom_manifest_yellow.json
@@ -6574,7 +6574,7 @@
"battleType": "BATTLE_TYPE_OLD_MAN",
"level": 5,
"map": "VIRIDIAN_CITY",
- "species": "WEEDLE",
+ "species": "RATTATA",
"text": "ViridianCityOldManText"
},
"overworldFx": {
@@ -19934,7 +19934,13 @@
"imageWidth": 16,
"label": "GamblerAsleepSprite"
}
- ]
+ ],
+ "surfPikachu": {
+ "imageBase": "surfing_pikachu",
+ "imageHeight": 96,
+ "imageWidth": 16,
+ "label": "SurfingPikachuSprite"
+ }
},
"symbols": {
"AbraPicBack": [
@@ -21369,6 +21375,22 @@
28,
16822
],
+ "FanClubChairPrintText1": [
+ 43,
+ 22070
+ ],
+ "FanClubChairPrintText2": [
+ 43,
+ 22210
+ ],
+ "FanClubChairPrintText3": [
+ 43,
+ 22243
+ ],
+ "FanClubChairPrintText4": [
+ 43,
+ 22257
+ ],
"FarfetchdPicBack": [
10,
28976
@@ -25293,6 +25315,18 @@
24,
22540
],
+ "SSAnneKitchenCook7EelsAuBarbecueText": [
+ 38,
+ 21363
+ ],
+ "SSAnneKitchenCook7PrimeBeefSteakText": [
+ 38,
+ 21414
+ ],
+ "SSAnneKitchenCook7SalmonDuSaladText": [
+ 38,
+ 21298
+ ],
"SSAnneKitchenCook7Text": [
24,
22545
@@ -26069,10 +26103,18 @@
21,
25898
],
+ "SilphCo9FNurseDontGiveUpText": [
+ 38,
+ 30668
+ ],
"SilphCo9FNurseText": [
23,
22467
],
+ "SilphCo9FNurseThankYouText": [
+ 38,
+ 30683
+ ],
"SilphCo9FNurseYouLookTiredText": [
38,
30622
@@ -26237,10 +26279,18 @@
32,
25380
],
+ "SurfingPikachuSprite": [
+ 63,
+ 28143
+ ],
"SwimmerPic": [
19,
20787
],
+ "TMNotebookText": [
+ 39,
+ 27310
+ ],
"TamerPic": [
19,
23374
@@ -26753,6 +26803,10 @@
6,
21103
],
+ "ViridianCityFisherYouCanHaveThisText": [
+ 45,
+ 18396
+ ],
"ViridianCityGambler1Text": [
6,
21055
@@ -26801,6 +26855,14 @@
6,
21043
],
+ "ViridianCityYoungster2CaterpieAndWeedleDescriptionText": [
+ 45,
+ 18125
+ ],
+ "ViridianCityYoungster2OkThenText": [
+ 45,
+ 18111
+ ],
"ViridianCityYoungster2Text": [
6,
21067
@@ -39174,7 +39236,30 @@
]
},
"labels": [
+ "FanClubChairPrintText1",
+ "FanClubChairPrintText2",
+ "FanClubChairPrintText3",
+ "FanClubChairPrintText4",
+ "MelanieBulbasaurText",
+ "MelanieOddishText",
+ "MelanieSandshrewText",
+ "MelanieText1",
+ "MelanieText2",
+ "MelanieText3",
+ "MelanieText4",
+ "MelanieText5",
+ "SSAnneKitchenCook7EelsAuBarbecueText",
+ "SSAnneKitchenCook7PrimeBeefSteakText",
+ "SSAnneKitchenCook7SalmonDuSaladText",
"SilphCo2FSilphWorkerFPleaseTakeThisText",
+ "SilphCo9FNurseDontGiveUpText",
+ "SilphCo9FNurseThankYouText",
+ "SilphCo9FNurseYouLookTiredText",
+ "TMNotebookText",
+ "TeachingHMsText",
+ "ViridianCityFisherYouCanHaveThisText",
+ "ViridianCityYoungster2CaterpieAndWeedleDescriptionText",
+ "ViridianCityYoungster2OkThenText",
"_AIBattleUseItemText",
"_AIBattleWithdrawText",
"_AbandonLearningText",
@@ -41860,15 +41945,7 @@
"_YeahText",
"_YourNameIsText",
"_ZapdosDexEntry",
- "_ZubatDexEntry",
- "MelanieText1",
- "MelanieText2",
- "MelanieText3",
- "MelanieText4",
- "MelanieText5",
- "MelanieBulbasaurText",
- "MelanieOddishText",
- "MelanieSandshrewText"
+ "_ZubatDexEntry"
],
"pointers": {
"AgathasRoom": {
diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua
index ce3a0feb..9f6f75fd 100644
--- a/tools/save-editor/App.lua
+++ b/tools/save-editor/App.lua
@@ -41,6 +41,10 @@ local S
-- vanilla records over an already-merged Data
local mods
local mouseClicked = false
+-- Wheel notches queued by App.wheelmoved since the last draw, handed to Kit
+-- there like mouseClicked is: LOVE delivers events before love.draw, so a
+-- notch is always spent by the frame that follows it (#595).
+local wheelY = 0
-- Which game's cache Data was loaded from. main.lua checks this before
-- opening the editor on a save from the other version, because the two
@@ -602,7 +606,7 @@ local function drawStatusBar(x, y, w, h)
and (ctrl .. "+S save . " .. ctrl ..
"+R reload . Esc clear selection . Close returns to the launcher")
or (ctrl .. "+S save . " .. ctrl ..
- "+R reload . Esc clear selection . arrows pan map . wheel zoom")
+ "+R reload . Esc clear selection . arrows pan map . wheel scrolls lists")
local hintW = Kit.textWidth("tiny", hint)
Kit.textRight("tiny", hint, x + w - pad, y + (h - Kit.textHeight("tiny")) / 2, PAL.faint)
local avail = w - 2 * pad - hintW - 14 * s
@@ -620,8 +624,9 @@ function App.draw()
local s = Kit.scale
local mx, my = love.mouse.getPosition()
- Kit.beginFrame(mx, my, mouseClicked)
+ Kit.beginFrame(mx, my, mouseClicked, wheelY)
mouseClicked = false
+ wheelY = 0
-- Modal shield. Kit has no z-order, so the picker cannot simply be drawn
-- last: the chrome and the panel underneath would take the same tap. The
-- shield goes up before anything dispatches and comes down only for the
@@ -699,9 +704,13 @@ end
function App.wheelmoved(x, y)
if not S then return end
+ -- The map tab spends the wheel on zoom; every other tab routes it through
+ -- Kit so whichever list the pointer is over takes it next draw (#595).
if S.tab == "map" and MapBrowser.wheelmoved then
MapBrowser.wheelmoved(S, y)
+ return
end
+ wheelY = wheelY + (y or 0)
end
function App.quit()
diff --git a/tools/save-editor/Kit.lua b/tools/save-editor/Kit.lua
index 8b99fc7f..9890e824 100644
--- a/tools/save-editor/Kit.lua
+++ b/tools/save-editor/Kit.lua
@@ -1,10 +1,12 @@
-- Immediate-mode widget kit for the save editor, drawn in the launcher's
-- visual language (see Theme.lua and SaveEditor.dc.html).
--
--- Call Kit.beginFrame(mx, my, clicked) once per love.draw() before any widget
--- and Kit.endFrame() after the last one; widgets read the frame's mouse state
--- to decide hover / click, and endFrame retires the text-input queue so a
--- keystroke is never applied twice.
+-- Call Kit.beginFrame(mx, my, clicked, wheel) once per love.draw() before any
+-- widget and Kit.endFrame() after the last one; widgets read the frame's mouse
+-- state to decide hover / click, and endFrame retires the text-input queue so a
+-- keystroke is never applied twice. The wheel notches accumulated since the
+-- last frame arrive the same way and are retired the same way: an unclaimed
+-- notch dies with the frame rather than scrolling something later (#595).
--
-- Hit testing is a plain rect with no z-order, so panels must draw
-- overlapping controls in dispatch order and every target is >= 26px tall
@@ -17,6 +19,7 @@ local PAL = Theme.PAL
local Kit = {}
Kit.mouseX, Kit.mouseY = 0, 0
Kit.mouseClicked = false -- left button pressed this frame
+Kit.wheelY = 0 -- wheel notches queued since the last frame (#595)
Kit.focus = nil -- id of the text field receiving keystrokes
Kit.time = 0
Kit.fonts = {}
@@ -58,9 +61,10 @@ local function canPrintf()
return G and type(G.printf) == "function"
end
-function Kit.beginFrame(mx, my, clicked)
+function Kit.beginFrame(mx, my, clicked, wheel)
Kit.mouseX, Kit.mouseY = mx, my
Kit.mouseClicked = clicked
+ Kit.wheelY = wheel or 0
if love and love.timer and love.timer.getTime then
Kit.time = love.timer.getTime()
end
@@ -68,8 +72,10 @@ end
-- Retire this frame's keystrokes. Anything typed while no field had focus is
-- dropped here rather than replayed into the next field that gets clicked.
+-- A wheel notch no list claimed retires with them, for the same reason.
function Kit.endFrame()
for i = #edits, 1, -1 do edits[i] = nil end
+ Kit.wheelY = 0
end
-- Rebuild the font set when the window size changes. `s` matched the
@@ -427,6 +433,29 @@ function Kit.pager(x, y, w, offset, total, perPage)
return offset, h
end
+-- ----------------------------------------------------------------- scroll
+-- Mouse wheel over a list body (#595): same offset contract as Kit.pager, so
+-- a list can carry both and stay on one page counter. Three rules, all of
+-- them consequences of Kit having no z-order:
+-- * only the list the pointer is inside takes the notch,
+-- * the notch is consumed, so two stacked lists cannot both eat it,
+-- * Kit.blockClicks shields it exactly as it shields Kit.press, or the
+-- panel under an open species picker would scroll through the modal.
+local SCROLL_ROWS = 3
+
+function Kit.scroll(x, y, w, h, offset, total, perPage)
+ local maxOffset = math.max(0, (total or 0) - (perPage or 0))
+ offset = Theme.clamp(offset or 0, 0, maxOffset)
+ if Kit.blockClicks or (Kit.wheelY or 0) == 0 then return offset end
+ if not Kit.hit(x, y, w, h) then return offset end
+ -- LOVE reports wheel-up as positive y; up moves the window toward the top
+ -- of the list, which is a smaller offset.
+ local rows = math.max(1, math.min(SCROLL_ROWS, perPage or SCROLL_ROWS))
+ local step = (Kit.wheelY > 0) and -rows or rows
+ Kit.wheelY = 0
+ return Theme.clamp(offset + step, 0, maxOffset)
+end
+
-- Clip drawing to a rect (list bodies). No-ops under the headless stub.
function Kit.pushClip(x, y, w, h)
-- A compact mobile viewport can leave a panel with no room for a list.
diff --git a/tools/save-editor/State.lua b/tools/save-editor/State.lua
index 5da15470..aeba4fb9 100644
--- a/tools/save-editor/State.lua
+++ b/tools/save-editor/State.lua
@@ -55,6 +55,7 @@ function State.new()
selectedItemId = nil,
selectedBagId = nil,
selectedPcId = nil,
+ itemPickOffset = 0, -- scroll position in the ADD ITEM list (#595)
bagOffset = 0,
pcOffset = 0,
diff --git a/tools/save-editor/panels/Items.lua b/tools/save-editor/panels/Items.lua
index ae1f9f68..3a40b68c 100644
--- a/tools/save-editor/panels/Items.lua
+++ b/tools/save-editor/panels/Items.lua
@@ -4,9 +4,10 @@
--
-- The picker is a searchable list rather than the old pair of arrows that
-- cycled one id at a time through ~250 items, which was the single worst
--- interaction in the editor. Badges sit in the wallet column as toggle
--- chips because they are boolean inventory flags, not stackable items, and
--- must not look like quantity rows.
+-- interaction in the editor. It scrolls under the mouse wheel too (#595):
+-- typing used to be the only way to reach an id past the first screenful.
+-- Badges sit in the wallet column as toggle chips because they are boolean
+-- inventory flags, not stackable items, and must not look like quantity rows.
local Bag = require("src.inventory.Bag")
local Theme = require("Theme")
@@ -97,8 +98,12 @@ function M.draw(S, Kit, x, y, w, h)
Kit.card(x, pickY, leftW, pickH)
Kit.caption(x + pad, pickY + pad, "ADD ITEM")
local qy = pickY + pad + Kit.textHeight("caption") + 8 * s
+ local prevQuery = S.itemQuery or ""
S.itemQuery = Kit.textfield("item-query", x + pad, qy, leftW - 2 * pad, 32 * s,
S.itemQuery or "", "search item ids...")
+ -- a new query is a new list: keep the first hit on screen rather than
+ -- leaving the view parked wherever the old result set had scrolled to
+ if S.itemQuery ~= prevQuery then S.itemPickOffset = 0 end
local choices = {}
for _, id in ipairs(S.cat.items) do
@@ -117,9 +122,14 @@ function M.draw(S, Kit, x, y, w, h)
local cRowH = 28 * s
local cGap = 5 * s
local visible = math.max(1, math.floor((listBottom - listTop) / (cRowH + cGap)))
+ -- #595: the wheel drives the same offset a pager would, so the whole
+ -- catalog is reachable with the mouse alone. Kit.scroll clamps, which is
+ -- also what pulls the view back when a narrower query shortens the list.
+ S.itemPickOffset = Kit.scroll(x + pad, listTop, leftW - 2 * pad,
+ listBottom - listTop, S.itemPickOffset or 0, #choices, visible)
Kit.pushClip(x + pad, listTop, leftW - 2 * pad, listBottom - listTop)
- for i = 1, math.min(visible, #choices) do
- local id = choices[i]
+ for i = 1, math.min(visible, #choices - S.itemPickOffset) do
+ local id = choices[S.itemPickOffset + i]
local ry = listTop + (i - 1) * (cRowH + cGap)
if Kit.row(x + pad, ry, leftW - 2 * pad, cRowH, id == S.selectedItemId,
PAL.green, 8 * s) then
@@ -130,10 +140,11 @@ function M.draw(S, Kit, x, y, w, h)
x + pad + 10 * s, ry + (cRowH - Kit.textHeight("mono")) / 2, PAL.text)
end
Kit.popClip()
- -- the overflow count rides the caption line, where it can never collide
+ -- the position counter rides the caption line, where it can never collide
-- with the list body or the two add buttons below it
if #choices > visible then
- Kit.textRight("micro", ("+%d more"):format(#choices - visible),
+ Kit.textRight("micro", ("%d-%d of %d"):format(S.itemPickOffset + 1,
+ math.min(S.itemPickOffset + visible, #choices), #choices),
x + leftW - pad, pickY + pad, PAL.faint)
elseif #choices == 0 then
Kit.text("mono", "no item matches", x + pad + 10 * s, listTop + 8 * s, PAL.faint)
@@ -195,6 +206,9 @@ function M.draw(S, Kit, x, y, w, h)
local rowGap = 6 * s
local perPage = math.max(1, math.floor((pagerY - 12 * s - rowsTop) / (rowH + rowGap)))
S.bagOffset = Ops.clamp(S.bagOffset or 0, 0, math.max(0, #order - perPage))
+ -- the wheel moves the same offset the pager below does (#595)
+ S.bagOffset = Kit.scroll(bagX + pad, rowsTop, listW - 2 * pad,
+ pagerY - 12 * s - rowsTop, S.bagOffset, #order, perPage)
if #order == 0 then
Kit.emptyBox(bagX + pad, rowsTop, listW - 2 * pad, 70 * s, "Bag is empty.")
@@ -221,6 +235,8 @@ function M.draw(S, Kit, x, y, w, h)
Kit.textRight("mono", ("%d kinds"):format(#pcOrder), pcX + listW - pad,
y + pad, PAL.caption)
S.pcOffset = Ops.clamp(S.pcOffset or 0, 0, math.max(0, #pcOrder - perPage))
+ S.pcOffset = Kit.scroll(pcX + pad, rowsTop, listW - 2 * pad,
+ pagerY - 12 * s - rowsTop, S.pcOffset, #pcOrder, perPage)
if #pcOrder == 0 then
Kit.emptyBox(pcX + pad, rowsTop, listW - 2 * pad, 70 * s,
"PC storage is empty. Items sent here have no slot cap.")