Compare commits

..

81 Commits

Author SHA1 Message Date
bryanthaboi 078bb1ade6 Merge pull request #634 from bryanthaboi/dev 2026-08-01 21:19:29 -04:00
bryanthaboi f205464aa4 Merge pull request #627 from spiritsnails/parity-fixes 2026-08-01 21:18:52 -04:00
spiritsnails b820d3917c fixing failed tests/harnesses 2026-08-01 19:13:03 -06:00
bryanthaboi fea81f03fc Merge branch 'dev' 2026-08-01 21:10:08 -04:00
bryanthaboi 75aa71a886 Merge pull request #629 from bryanthaboi/dependabot/github_actions/actions/upload-artifact-7
build(deps): bump actions/upload-artifact from 4 to 7
2026-08-01 20:54:55 -04:00
bryanthaboi 398eefc380 Merge pull request #630 from bryanthaboi/dependabot/github_actions/actions/checkout-7
build(deps): bump actions/checkout from 4 to 7
2026-08-01 20:54:41 -04:00
bryanthaboi b549e319c2 Update README.md 2026-08-01 20:54:08 -04:00
dependabot[bot] 1a57b67e14 build(deps): bump actions/checkout from 4 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 23:57:13 +00:00
bryanthaboi 378246728b Merge pull request #628 from spiritsnails/fix/oaks-lab-rival-music 2026-08-01 19:57:10 -04:00
dependabot[bot] 41e326f376 build(deps): bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 23:57:09 +00:00
bryanthaboi f9adeca5ca Merge pull request #618 from castdrian/metal 2026-08-01 19:56:29 -04:00
bryanthaboi 838f87c224 Merge pull request #619 from ShaneMcGovernIE/fix/viridian-old-man-yellow-617 2026-08-01 19:55:49 -04:00
spiritsnails 5b519165fc Fix #596: missing rival battle music in Oak's Lab
The Yellow port (oaks_lab_yellow.lua) already swaps in Music_MeetRival
for the "Hey wait! Let's check out our Pokemon!" challenge and restores
Music_OaksLab once the rival walks out, but the Red/Blue script never
did, leaving the lab battle silent before and after the fight.
2026-08-01 17:41:35 -06:00
spiritsnails 6b012a31ce Guarantee the post-battle fade-in by moving it into BattleState:finish()
pushBattle wrapped onFinish to push Transition.battleReturn on any
non-lose result, but that only fires for callers that route through
pushBattle correctly. Moving the same push into BattleState:finish()
instead -- the one choke point every battle (wild, trainer, walk-up,
scripted, link) already passes through on exit -- makes the fade
unconditional rather than dependent on each call site's wiring.
2026-08-01 16:41:51 -06:00
Adrian Castro 1a2b23a066 ci: configure dependabot for actions 2026-08-02 00:26:48 +02:00
spiritsnails f109530c5f Route scripted battles through pushBattle so the entry wipe actually plays
Commands.lua's start_battle and old-man-demo battle commands pushed
BattleState straight onto the stack, bypassing pushBattle (and therefore
BattleTransition) entirely. Every script-triggered battle -- gym leaders,
the rival, Giovanni, the catch tutorial -- cut straight to the battle
screen with no transition wipe. Only the walk-up trainer-sight path
(OverworldState:engageTrainer) went through pushBattle already.
2026-08-01 16:12:29 -06:00
spiritsnails 4cb132199c Fix ADVANCED palette pack using Gen 2 per-species palettes instead of Gen 1's
pokered-gbc's palettes.asm carries two species->palette tables gated on
GEN_2_GRAPHICS; data/palettes_gbc.lua had imported the per-species Gen 2
table, shaded for Gen 2 sprite art this port doesn't use, instead of Gen
1's own assignments. Bulbasaur wore PAL_BULBASAUR's red-orange, Squirtle
wore PAL_SQUIRTLE's shell brown on his head. Palette values are unchanged;
only which palette each species points at is corrected.
2026-08-01 16:04:22 -06:00
Adrian Castro fe16b3259c ci(ios): comment artifacts after pull request builds 2026-08-02 00:01:53 +02:00
Adrian Castro 2e46954fe0 ci(ios): use release signing in verification 2026-08-01 23:56:04 +02:00
spiritsnails aedc63c40d Port timing/parity fixes, seamless battle transitions, faithful-res lock, and zoom-aware UI anchoring
Ports from a downstream fork, hand-surgered hunk-by-hunk to exclude the
fork's randomizer/pokescript work and to skip a FixedStep jitter-tolerance
attempt that never fixed the stutter it targeted.

- src/core/Timing.lua: hardware-accurate frame-delay catalog ported from
  pret/pokered, feeding BattleState:waitNext, EffectRegistry's miss/crit
  beats, TextBox/ChoiceBox scroll and prompt holds, and the battle
  silhouette slide/shake/blink/faint timings.
- Seamless battle transitions: Renderer:drawBattleWipe replaces the old
  160x144-only cascade with one wipe drawn over the whole surface at any
  zoom or window size; BattleTransition's per-style frame lengths are
  corrected against pokered-c's derivation; Transition.battleReturn adds
  the post-battle GBFadeInFromWhite the port never had.
- BATTLE SIZE / BATTLE BG options (BattleState:wantsFillScale/bgMode,
  Game.fillScaleInStack/worldBgBattleDim): battle surface can fill the
  window instead of the fixed integer letterbox, and the area around it
  can show white/black/the dimmed overworld instead of only white.
- src/core/FaithfulRes.lua: locks the window to an exact 160x144 multiple.
- Zoom-aware UI anchoring: Renderer:uiScale steps the UI down with survey
  zoom (gated to worldActive so the title/intro never shrink);
  Renderer:setUIAnchor lets TextBox, ChoiceBox, and an opted-in Menu
  (the START menu) pin themselves to a screen edge instead of the
  zoomed-out letterbox.
2026-08-01 15:52:34 -06:00
Adrian Castro 00d13d3c35 ci(ios): comment release artifacts on pull requests 2026-08-01 23:43:12 +02:00
Adrian Castro 1b8b3ad538 ci(ios): build device release on canonical repo 2026-08-01 23:43:11 +02:00
Adrian Castro 5f75cfd691 ci: verify release builds on fork runners 2026-08-01 23:43:11 +02:00
Adrian Castro 5f89def2ce ci: select hosted runners for forks 2026-08-01 23:43:08 +02:00
Adrian Castro 7c26eb9a24 ci(ios): gate hosted build on iOS changes 2026-08-01 23:43:07 +02:00
Shane McGovern d951fe8fc5 Fix Viridian old man tutorial dialog in Yellow (#617) 2026-08-01 22:38:09 +01:00
bryanthaboi ac01135ca8 Merge pull request #615 from bryanthaboi/dev 2026-08-01 16:53:54 -04:00
bryanthaboi 5e89e35e02 Merge pull request #614 from castdrian/metal 2026-08-01 16:52:41 -04:00
bryanthaboi 7e7afeaf82 Mask the self-hosted runner's home path in release logs 2026-08-01 16:45:37 -04:00
Adrian Castro c6fa6d294b fix(ios): support LÖVE 12 file bridge 2026-08-01 22:22:49 +02:00
bryanthaboi fc2d17fc78 Merge pull request #612 from bryanthaboi/dev 2026-08-01 16:03:28 -04:00
bryanthaboi 9f072285a6 Merge pull request #611 from ShaneMcGovernIE/fix/oak-last-ball-601 2026-08-01 16:00:52 -04:00
bryanthaboi c58ebe4b9b Merge pull request #609 from castdrian/metal 2026-08-01 15:59:33 -04:00
Shane McGovern 0dd187fe30 Fix wrong dialogue on Prof. Oak's leftover ball (#601)
After the player picks a starter and the rival takes his, every ball on
the lab table fell through to 'Those are POKé BALLs' instead of the
leftover-mon line.  Port the pokered OaksLabSelectedPokeBallScript ->
OaksLabLastMonScript beat: with EVENT_GOT_STARTER set, Oak turns to face
the player and reads 'That's PROF.OAK's last Pokémon!'.  The ROM's
'#MON' ligature is spelled out as Pokémon.

The renumbering also drops the table's nine out-of-range 'jump 21'
rows (run-time 'end' idioms) for explicit "end" targets, so the
script now validates cleanly.

Tests: T2 suite drives the ball talk table through a ScriptRunner-
compatible executor (leftover text, pre-escort text, pre-pick offer)
plus a T3 driver that talks to the leftover ball in a real game.
2026-08-01 20:55:31 +01:00
Adrian Castro ea78792c03 fix(ios): build releases with LÖVE 12 2026-08-01 21:45:23 +02:00
bryanthaboi 9954225701 Merge pull request #603 from bryanthaboi/dev 2026-08-01 14:32:23 -04:00
bryanthaboi 6bb2e078c0 Scrub mod manifest strings to valid UTF-8
A manifest whose name, version, description, or category carries invalid
UTF-8 (a BOM, Latin-1 bytes) crashed the launcher's MODS panel, since
love.graphics.printf raises on invalid UTF-8. Manifest.validate now drops
invalid bytes and a leading BOM from those strings, in place so the
badge's raw.category read agrees.
2026-08-01 14:22:30 -04:00
bryanthaboi 323c59e54f Merge pull request #594 from bryanthaboi/dev 2026-08-01 13:03:22 -04:00
bryanthaboi d26d63ed38 Merge pull request #588 from techmore/fix/prof-oaks-pc-flow 2026-08-01 12:58:03 -04:00
bryanthaboi 2009df3dd1 CLOSES #575, CLOSES #578, CLOSES #584, CLOSES #589 2026-08-01 12:56:13 -04:00
techmore f0f3e9634b fix: restore PROF. OAK's PC intro, jingle timing and closing link
CLOSES #576

The launcher skipped oaks_pc.asm's whole session -- the access text and
the 'Want to get your #DEX rated?' YES/NO -- and played the Pokedex_Rating
jingle the moment the entry was picked, before any text printed.  Now the
access text types out, the YES/NO pops, and only once the completion line
and the rating tier have printed does the jingle sound (DisplayDexRating
-> PlayPokedexRatingSfx, auto.wait hands the box to the A/B path), then
the 'Closed link to PROF.OAK's PC.' tail closes the session.
2026-08-01 11:01:19 -04:00
bryanthaboi ca1beaefe7 Merge pull request #582 from castdrian/metal 2026-08-01 10:41:40 -04:00
bryanthaboi 24cf1b3317 Merge pull request #583 from BartInTheField/fix-encounter-slide-577 2026-08-01 10:41:25 -04:00
Bart in 't Veld 88e2ec2042 Fix encounter silhouette slide speed and blackout (#577)
The battle intro slide ran at 4px/frame over 40 frames -- twice the
original speed -- and drew the pics in their normal palette instead of
as black silhouettes.

SlidePlayerAndEnemySilhouettesOnScreen scrolls SCX from $90 to 0 at
2px/frame (~72 frames) under the %11100100 silhouette palette, only
running SET_PAL_BATTLE once the pics land. Match that: introSlide starts
at 80 with a 2px/frame offset (80 frames over the full 160px width), and
picImage bakes both pics through PAL_BLACK while introSlide > 0, exactly
like the evolution movie (#279).

parity_battle_intro_chrome waited a hardcoded 45 frames for the slide to
land; bumped to 85 to cover the slower slide.
2026-08-01 15:25:54 +02:00
Adrian Castro be60c7c1d5 fix(ios): present voxel pipeline canvases 2026-08-01 14:37:18 +02:00
bryanthaboi 9be21380c7 Merge pull request #581 from bryanthaboi/dev
bug smashing on a saturday
2026-08-01 08:14:38 -04:00
bryanthaboi 22fd196045 Merge pull request #579 from castdrian/metal
feat: metal support and make ios usable
2026-08-01 08:13:34 -04:00
bryanthaboi d5293dffb8 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-01 08:10:22 -04:00
bryanthaboi 9326b07583 CLOSES #415, CLOSES #484, CLOSES #488, CLOSES #492, CLOSES #497, CLOSES #541, CLOSES #559, CLOSES #562, CLOSES #563, CLOSES #564, CLOSES #565, CLOSES #566, CLOSES #567, CLOSES #568, CLOSES #569, CLOSES #570, CLOSES #571, CLOSES #572 2026-08-01 08:10:20 -04:00
Adrian Castro 90437a5d8d fix(ios): export saves from Love directory 2026-08-01 13:07:36 +02:00
Adrian Castro c4ec5dc1c5 test(ios): cover save export picker 2026-08-01 12:58:42 +02:00
bryanthaboi 3b19826f1e Merge pull request #543 from BartInTheField/render-second-screen-seam 2026-08-01 06:56:38 -04:00
Adrian Castro 95e4715bc3 fix(ios): declare Love 12 compatibility 2026-08-01 12:54:05 +02:00
Adrian Castro 81edc97d86 fix(ios): package updater boot module 2026-08-01 12:44:26 +02:00
Adrian Castro 92c8bac714 fix(ios): support direct picker results 2026-08-01 12:36:18 +02:00
Adrian Castro 1df04aa18d fix(ios): route native picker results 2026-08-01 12:32:22 +02:00
Bart in 't Veld 218b9249a2 render: add render.compose seam + second-screen bridge
Expose a generic seam so a mod can drive a second screen without the
engine owning any dual-screen layout policy:

- render.compose hook in Renderer:endFrame hands a mod the finished
  world + UI canvases, their SGB zones, the frame metrics,
  Renderer:blitCanvas (lifted from the internal blit closure) and the
  SecondScreen bridge. Return true to take over the window; no wrap (or
  calling next) runs the normal single-window composite byte-for-byte.
- SecondScreen.lua + the Android Presentation bridge (love_android_
  secondary_* in common/android.cpp, GameActivity secondary display)
  as the optional physical-second-display transport.

No battle-render changes: a mod lays out the two screens (including any
battle split) itself. Ships with a unit test, no-mod parity via
gate_hooks, and docs/modding.md (D14).
2026-08-01 09:35:24 +02:00
bryanthaboi 50947eec2a Merge pull request #489 from mresnick67/android-step-bridge 2026-08-01 00:21:40 -04:00
bryanthaboi 2624bb0210 Merge pull request #537 from sirj0k3r/hook/exp 2026-08-01 00:21:20 -04:00
bryanthaboi 6e9685f302 Merge pull request #538 from sirj0k3r/hook/low-health 2026-08-01 00:21:10 -04:00
bryanthaboi 898bf0c71e Merge pull request #560 from bryanthaboi/dev 2026-07-31 23:55:26 -04:00
bryanthaboi 5a7add8eaa Merge pull request #554 from andrewqsantos/feature/mobile-safe-area 2026-07-31 23:54:11 -04:00
bryanthaboi 66334dfacd Merge pull request #558 from erereck/fix/mod-configurable-bag-capacity 2026-07-31 23:53:54 -04:00
bryanthaboi a33f3b1ceb mobile fixes 2026-07-31 23:47:28 -04:00
erereck 6e724cedf7 Honor modded bag capacity 2026-08-01 00:32:00 -03:00
bryanthaboi e949c78639 Merge pull request #556 from bryanthaboi/dev 2026-07-31 23:03:09 -04:00
bryanthaboi 9bcfdb1f0d mobile fixes 2026-07-31 23:01:25 -04:00
Andrew Quenehen fccb122c59 Respect iOS/Android safe areas in launcher and touch chrome.
Layout interactive UI against love.window.getSafeArea so notch, Dynamic Island, and home-indicator insets no longer clip controls, while keeping the game framebuffer edge-to-edge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 23:53:26 -03:00
bryanthaboi e2b2a5fb9f Merge pull request #555 from bryanthaboi/dev 2026-07-31 22:41:53 -04:00
bryanthaboi f2a3e5f05b mobile updates CLOSES #482, CLOSES #553 2026-07-31 22:39:01 -04:00
bryanthaboi 5f855568c8 Merge pull request #539 from hernan0078/ios-support 2026-07-31 22:16:24 -04:00
bryanthaboi 24696e3be2 Merge pull request #524 from kaosregulator/claude/multi-game-low-end-support-o16gz5
Claude/multi game low end support  and some fixes o16gz5
2026-07-31 20:26:09 -04:00
sirj0k3r 33899c237e + Implemented tests for battle.low_health_alarm hook 2026-07-31 22:17:19 +01:00
sirj0k3r 0fad222569 + Implemented tests for battle.catch_exp and battle.exp_award hooks 2026-07-31 22:10:26 +01:00
hernan0078 9c5f33e187 Fix #482: guard love.system.pickFile so Import ROM cannot crash
Pressing Import ROM on iOS takes the whole app down:

  src/import/RomImporter.lua: attempt to call field 'pickFile' (a nil value)

love.system.pickFile is a NATIVE BRIDGE, not part of LOVE. It exists only on
builds that compiled one -- Android, and iOS builds patched by
mobile/ios/patch_love_src.py -- so on a build without it the field is simply
nil. RomImporter:546 routes iOS down the same path as Android
(`mobileOS == "Android" or mobileOS == "iOS"`), and all three mobile pick
sites called the field unguarded.

That is why the reports say "any version": nothing about it is version
specific. Red, Blue and Yellow all reach the same call.

Every one of those call sites already handles a device with no document
picker -- Choose falls back to "No picker available, copy your ROM into:"
plus the save directory, and the mod / save rows have their own notices --
and love.system.createFile at its single call site was already guarded this
way. These three were not, so the fallback that was written for exactly this
case could never be reached.

Route them through one small helper that answers false when the bridge is
absent. A build without a picker now degrades to the copy-into-the-save-folder
flow, which on iOS is a working path: the Files app exposes the app's
Documents folder and GRBootstrap sweeps what lands there into the save dir.

tests/rom_importer_no_picker_test.lua covers Import ROM, Import mod and
Import save with the bridge missing, and asserts the picker is still used
when it is present. Reverting the fix reproduces the reported error exactly.

Reported in #482 (confirmed by three people) and #512.
2026-07-31 16:38:15 -04:00
sirj0k3r ddcce5b95a + Implemented Exp hook 2026-07-31 21:14:23 +01:00
sirj0k3r 6fd9741fab + Implemented Low Health Alert hook for modding 2026-07-31 21:12:21 +01:00
Myles Resnick caa65182dc Android: hardware step-counter bridge for step-sync mods
love.system.syncHealthSteps() now exists on Android, matching the iOS
Health bridge merged in #452 and using the same JNI route as the SAF
picker (wrap_System.cpp -> System.cpp -> common/android.cpp ->
GameActivity over JNI):

- GameActivity.syncHealthSteps: one-shot read of the hardware
  TYPE_STEP_COUNTER sensor (cumulative since boot, counted by the OS
  whether or not any app runs). The reading is anchored in
  SharedPreferences so a walk is never credited twice; a reading below
  the anchor means the phone rebooted, which re-anchors without
  crediting. Deltas (50k clamp) merge into steps_pending.json in the
  save identity dir - the same contract as the iOS GRHealthBridge, so
  the Pokewalker mod works unchanged on both platforms.
- ACTIVITY_RECOGNITION declared in the app manifest (Android 10+
  runtime prompt on first sync; granted -> the sensor read runs
  immediately via onRequestPermissionsResult). The build script's
  permission trim leaves it alone.
- Nothing in the base game calls the new seam; without a consumer mod
  the only cost is one dormant manifest permission.
- build_android.sh: shadow-build from a space-free temp dir when the
  checkout path contains spaces - ndk-build is GNU make underneath and
  cannot cope with paths like "xCode Projects".
- mobile/ANDROID.md: step-bridge dev notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 11:11:54 -04:00
Claude 74eeb411ba Add luacheck config + lint script
Introduce a tuned .luacheckrc and scripts/lint.sh so the engine has a
standing static-analysis baseline -- the tool that would have caught both
bugs in the previous commit before they shipped.

The config is high-signal by design: it keeps the categories that catch
real defects (undefined globals/locals, unused values, unreachable code)
and mutes the cosmetic ones the codebase deliberately lives with (a self/dt
an interface requires but a method ignores, documented empty fall-through
branches, long lines). It marks `love` mutable (games assign callbacks onto
it) and teaches it LuaJIT's table.unpack.

.luacheckrc is tracked via a .gitignore exception, matching how .github and
.gitignore opt out of the blanket dotfile ignore.

`luacheck src` now reports 7 benign warnings and 0 errors, down from 185.
Also drop one dead `require` (ItemEffects loaded src.pokemon.Pokemon and
never used it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
2026-07-31 14:35:40 +00:00
Claude 040ca3f332 Fix two latent bugs surfaced by static analysis
Both are code paths that never run in a green test today but crash or
misbehave the moment a mod or a link failure exercises them.

1. Music.lua: applyVolume built its `music.volume` hook context from the
   private `state` table, but was defined *above* `local state = {...}`, so
   those reads bound to the nil global `state`. Any mod registering the
   music.volume hook crashed with "attempt to index a nil value (global
   'state')" the first time a volume was applied. Forward-declare `state`
   above applyVolume. Regression test drives a file-backed song through the
   hook and asserts the context resolves.

2. Tournament.lua: `local battle, why = isHost and newHost() or newGuest()`
   had two defects. The and/or idiom truncates a call to its first result,
   so `why` (the specific failure reason) was always dropped and every link
   failure showed the generic "Link battle can't start" instead of e.g.
   "same mods on both games". Worse, when a host's newHost() returned nil,
   the `or` fell through and wrongly called newGuest() as the host. Split
   into an explicit if/else so the reason is preserved and each role calls
   its own constructor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
2026-07-31 14:34:58 +00:00
Claude c8e035d332 Add graphics performance tier for low-end devices
Introduce an OPTIONS -> PERFORMANCE setting that scales the port's
optional presentation extras down for weaker hardware, so older/lower-end
devices can run the game smoothly.

The tier governs the three heaviest non-faithful extras -- the 3D TILT,
the GBC FX post-process shader, and survey ZOOM (which renders connected
neighbor maps) -- plus a hard FPS ceiling. It never touches game logic,
which is fixed-step off dt, so every tier plays identically.

- src/core/Performance.lua: tiers (auto/high/balanced/low), a conservative
  device auto-detect (ARM handhelds -> low, phones -> balanced, normal
  desktops -> high), per-tier caps, and the option-row cycle. Zero
  requires, like GameVersion.
- Game:applyOptions clamps the *live* presentation state against the tier
  without rewriting stored options, so a lower tier hides the player's
  TILT/GBC FX/ZOOM/FPS choices and a higher tier restores them exactly.
- Zoom.offsetRange floors the range at FIT when survey is disallowed, so
  the option row, hotkey, and mouse wheel all stop at close-up on LOW.
- New save.options.performance default "auto"; OPTIONS row heads the
  display group and re-applies live.
- Tests: tests/engine/performance_tiers.lua (ROM-free); mod_ui_tests row
  golden updated for the spliced row.

AUTO resolves to HIGH on a normal desktop and on every options.lua that
predates the option, so the common case is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
2026-07-31 01:44:17 +00:00
165 changed files with 14020 additions and 873 deletions
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
+83 -6
View File
@@ -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
@@ -0,0 +1,43 @@
name: iOS artifact comment
on:
workflow_run:
workflows: [ci]
types: [completed]
permissions:
contents: read
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: comment iOS artifact
if: steps.artifact.outputs.pr_number != ''
uses: thollander/actions-comment-pull-request@v3
with:
message: |
#### iOS Release IPA
- [Download gen1recomp.ipa](${{ steps.artifact.outputs.artifact_url }})
<sub>Automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
pr-number: ${{ steps.artifact.outputs.pr_number }}
comment-tag: ios-build-result
github-token: ${{ github.token }}
+28 -9
View File
@@ -42,11 +42,17 @@ 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
# directory; mask it first so absolute paths in every later step's
# output show up as *** in the public workflow logs.
- name: Mask runner paths
run: echo "::add-mask::$HOME"
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
@@ -121,6 +127,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"
@@ -170,14 +177,23 @@ jobs:
set -euo pipefail
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
- name: Build iOS
- name: Install xcbeautify
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 }}"
brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
- name: Build iOS
env:
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
run: |
set -euo pipefail
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 Anbernic RG34XXSP port
run: |
@@ -187,6 +203,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}"
@@ -221,6 +238,7 @@ jobs:
echo "Notarized + stapled ✓"
- name: Stage release assets
if: github.repository == 'bryanthaboi/gen1recomp'
id: assets
run: |
set -euo pipefail
@@ -260,6 +278,7 @@ jobs:
cat "$outdir/sha256sums.txt"
- name: Publish GitHub Release
if: github.repository == 'bryanthaboi/gen1recomp'
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -365,6 +384,6 @@ jobs:
echo "Published release $tag"
- 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
+1
View File
@@ -16,6 +16,7 @@ __pycache__/
.*
!.github/
!.gitignore
!.luacheckrc
# Android build outputs / local SDK path / packaged payload (keep love-android sources)
mobile/android/app/build/
+43
View File
@@ -0,0 +1,43 @@
-- Static-analysis config for `luacheck` (https://luacheck.readthedocs.io).
--
-- Run it over the engine with: luacheck src (or scripts/lint.sh)
--
-- The point is a high-signal baseline: the categories left on are the ones
-- that catch real defects -- undefined globals/locals (the class that hid a
-- `music.volume` crash: applyVolume read a `state` that was still the nil
-- global), unused values, unreachable code, redefinitions. The cosmetic
-- categories the codebase deliberately lives with (a `self`/`dt` an
-- interface requires but a given method ignores, documented empty
-- fall-through branches, the odd long line) are muted so they don't drown
-- the signal.
std = "luajit"
-- LÖVE exposes `love` as a mutable table: games assign their callbacks onto
-- it (love.wheelmoved, love.run, ...), so it is a regular global, not
-- read-only -- otherwise every callback registration reads as a violation.
globals = { "love" }
read_globals = {
"jit",
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
-- it; without this, every `table.unpack` reads as an undefined field.
table = { fields = { "unpack" } },
}
-- Vendored/native trees and the test suites have their own conventions.
exclude_files = {
"mobile/",
"tests/",
"tools/save-editor/",
}
ignore = {
"212", -- unused argument -- self/dt kept for a shared method signature
"213", -- unused loop variable -- `for _, v in` where only v is wanted
"421", -- shadowing a local -- deliberate re-use in a few tight scopes
"431", -- shadowing an upvalue
"432", -- shadowing an argument
"542", -- empty if branch -- documented fall-throughs, not gaps
"631", -- line is too long
}
+27
View File
@@ -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.
> [!WARNING]
> **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.
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
@@ -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
@@ -108,6 +124,17 @@ supported out of the box.
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
and persist in `options.lua`.
### Low-end devices
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX),
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
normal desktops → HIGH, unchanged). It only scales presentation; the
fixed-step game logic is identical on every tier, and a lower tier hides
your tilt/zoom/GBC-FX preferences without forgetting them. Details in
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
### Rulesets
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
+1 -1
View File
@@ -49,7 +49,7 @@ function love.conf(t)
t.window.minwidth = 480
t.window.minheight = 360
end
t.version = "11.5"
t.version = love._os == "iOS" and "12.0" or "11.5"
t.window.vsync = 1
t.modules.joystick = true
t.modules.physics = false
+165 -151
View File
@@ -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 = {
+9 -7
View File
@@ -1,10 +1,9 @@
-- BikeShop (BIKE_SHOP) flavor dialogue
-- Source: pokered/scripts/BikeShop.asm, pokered/text/BikeShop.asm
--
-- TEXT_BIKESHOP_CLERK is skipped: it drives the actual voucher-for-bicycle
-- exchange (YesNoChoice purchase menu, GiveItem, RemoveItemByID, SetEvent
-- EVENT_GOT_BICYCLE). That is a significant standalone feature outside the
-- scope of these two flavor NPCs and is left unported here.
-- TEXT_BIKESHOP_CLERK lives in data/scripts/story2.lua (M.BIKE_SHOP): the
-- voucher exchange and the BICYCLE/CANCEL price window need more than
-- command rows (#568).
return {
BIKE_SHOP = {
@@ -20,12 +19,15 @@ return {
-- CheckEvent EVENT_GOT_BICYCLE ; jr nz, .gotBike
-- before the player owns a bike -> TheseBikesAreExpensiveText
-- after the player owns a bike -> CoolBikeText
-- The check reads the bag, not the event: the port hands out the
-- BICYCLE itself (a key item, so it cannot be tossed), and that also
-- reads right on saves written before the clerk set the event (#567).
TEXT_BIKESHOP_YOUNGSTER = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_BICYCLE" }, -- 2
{ "jump_if_true", 5 }, -- 3
{ "check_item", "BICYCLE" }, -- 2
{ "jump_if_true", 6 }, -- 3
{ "show_text", "_BikeShopYoungsterTheseBikesAreExpensiveText" }, -- 4
{ "jump", 6 }, -- 5
{ "jump", "end" }, -- 5
{ "show_text", "_BikeShopYoungsterCoolBikeText" }, -- 6
},
},
@@ -35,14 +35,25 @@ local function middleAgedMan(game, ow, npc, done)
-- .loop: print WhichBadgeText, then show the badge list menu again
push(game, t._CeruleanBadgeHouseMiddleAgedManWhichBadgeText, function()
local ListMenu = require("src.ui.ListMenu")
local Strings = require("src.core.Strings")
local items = {}
for _, id in ipairs(BADGE_ORDER) do
local idef = game.data.items[id]
items[#items + 1] = { label = idef and idef.name or id, value = id }
end
-- PrintListMenuEntries prints ListMenuCancelText once it hits the
-- list's $FF terminator (home/list_menu.asm), so every
-- DisplayListMenuID list ends in an on-screen CANCEL row; picking it
-- takes DisplayListMenuIDLoop's ExitListMenu path, the same .done
-- exit as B (#569)
items[#items + 1] = { label = Strings("CANCEL") }
local menu = ListMenu.new(game, "", items, {
onChoose = function(item)
game.stack:pop()
if not item.value then
push(game, t._CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText, done)
return
end
push(game, t[BADGE_TEXT[item.value]], loop)
end,
onCancel = function()
+41
View File
@@ -85,6 +85,47 @@ return {
alreadyGotLabel = "_GameCornerGentlemanCloselyWatchTheReelsText",
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
}),
-- Yellow keeps all three giveaways with the same events and amounts
-- but renames the objects and their text labels: FISHING_GURU ->
-- FISHING_GURU1, CLERK2 -> MIDDLE_AGED_MAN2, GENTLEMAN ->
-- FISHING_GURU2 (pokeyellow/scripts/GameCorner.asm). The Red/Blue
-- keys above never match there, so Yellow needs its own three (#552).
TEXT_GAMECORNER_FISHING_GURU1 = coinGiver({
event = "EVENT_GOT_10_COINS",
amount = 10,
askLabel = "_GameCornerFishingGuru1WantToPlayText",
askFallback = "Kid, do you want\nto play?",
receivedLabel = "_GameCornerFishingGuru1Received10CoinsText",
coinCaseFullLabel = "_GameCornerFishingGuru1DontNeedMyCoinsText",
coinCaseFullFallback = "You don't need my\ncoins!",
alreadyGotLabel = "_GameCornerFishingGuru1WinsComeAndGoText",
alreadyGotFallback = "Wins seem to come\nand go.",
}),
TEXT_GAMECORNER_MIDDLE_AGED_MAN2 = coinGiver({
event = "EVENT_GOT_20_COINS_2",
amount = 20,
askLabel = "_GameCornerMiddleAgedMan2WantSomeCoinsText",
askFallback = "What's up? Want\nsome coins?",
receivedLabel = "_GameCornerMiddleAgedMan2Received20CoinsText",
coinCaseFullLabel = "_GameCornerMiddleAgedMan2YouHaveLotsOfCoinsText",
coinCaseFullFallback = "You have lots of\ncoins!",
alreadyGotLabel = "_GameCornerMiddleAgedMan2INeedMoreCoinsText",
alreadyGotFallback = "Darn! I need more\ncoins for the\vPOKéMON I want!",
}),
TEXT_GAMECORNER_FISHING_GURU2 = coinGiver({
event = "EVENT_GOT_20_COINS",
amount = 20,
askLabel = "_GameCornerFishingGuru2ThrowingMeOffText",
askFallback = "Hey, what? You're\nthrowing me off!\vHere are some\vcoins, shoo!",
receivedLabel = "_GameCornerFishingGuru2Received20CoinsText",
coinCaseFullLabel = "_GameCornerFishingGuru2YouGotYourOwnCoinsText",
coinCaseFullFallback = "You've got your\nown coins!",
alreadyGotLabel = "_GameCornerFishingGuru2CloselyWatchTheReelsText",
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
}),
},
},
}
+2 -1
View File
@@ -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
+21 -5
View File
@@ -6,6 +6,8 @@
-- takes it ("I'll take this one, then!") and both balls disappear.
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
-- OaksLabRivalTakePokeBallScript.
-- * Leftover ball (after the pick): Oak turns and reads the last-mon
-- line instead of re-offering the starter (OaksLabLastMonScript, #601).
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak
-- has walked you in, else "gramps isn't around" (#218); with
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
@@ -23,7 +25,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "jump_if_true", 20 }, -- 2
-- no picking until Oak has walked you in (OaksLabScript gating)
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 20 }, -- 4
{ "jump_if_false", 22 }, -- 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;
@@ -31,7 +33,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "push_screen", "DexEntryMenu",
{ species = species, forceOwned = true } }, -- 5
{ "ask", askText }, -- 6
{ "jump_if_false", 21 }, -- 7
{ "jump_if_false", "end" }, -- 7
-- 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.
@@ -52,9 +54,16 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
or "BULBASAUR" } }, -- 17
{ "jump", 21 }, -- 18
{ "jump", 21 }, -- 19 (spacer)
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
{ "jump", "end" }, -- 18
{ "jump", "end" }, -- 19 (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
}
end
@@ -245,7 +254,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
@@ -285,6 +299,8 @@ return {
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
table.insert(rows, { "move_npc_to", 1, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
-- restore the lab theme once he's walked out, same as the Yellow port
table.insert(rows, { "play_music", "Music_OaksLab" })
ow.runner:run(rows, { npc = rival })
return true
end
+21 -9
View File
@@ -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 },
@@ -143,17 +147,25 @@ return {
-- OaksLabRivalExclamationScript: "!" over the rival
{ "emote", RIVAL, "shock" },
}
-- .RivalPushesPlayerAwayFromEeveeBall + the PAD_RIGHT x2 shove:
-- the rival cuts across to the ball WHILE the player standing
-- below it is bumped two tiles right (both movements run in the
-- same beat, so the walk overlaps the shove like the original)
-- .RivalPushesPlayerAwayFromEeveeBall is DOWN then RIGHT x3 (the $07
-- bytes are Yellow's own step-right encoding, decoded by
-- engine/overworld/movement.asm Func_5288 -> Func_532b), and the
-- PAD_RIGHT x2 shove is NOT queued alongside it:
-- OaksLabRivalTakesPokeballScript .asm_1c564 polls every frame and
-- only simulates the pair once wNPCNumScriptedSteps reads 1 -- i.e.
-- as the rival begins the LAST byte, the step onto the tile the
-- player is standing on. Starting both on one row had Red stroll
-- off the Eevee while the rival was still at the top of the table
-- (#559).
if py == 4 then
rows[#rows + 1] = { "walk_npc", RIVAL,
{ "down", "right", "right", "right" }, { wait = false } }
rows[#rows + 1] = { "walk_npc", RIVAL, { "down", "right", "right" } }
-- this one runs concurrently with the shove below
rows[#rows + 1] = { "walk_npc", RIVAL, { "right" }, { wait = false } }
rows[#rows + 1] = { "face_player_dir", "left" }
rows[#rows + 1] = { "move_player", "right", 2 }
-- let the rival finish the last stretch to (7,4)
rows[#rows + 1] = { "wait", 40 }
-- move_player blocks for both tiles, so the rival has already
-- landed on (7,4); this is just the beat before he turns up
rows[#rows + 1] = { "wait", 20 }
else
rows[#rows + 1] = { "move_npc_to", RIVAL, 7, 4 }
end
+130 -11
View File
@@ -413,22 +413,141 @@ M.POKEMON_FAN_CLUB = {
},
}
-- BikeShopClerkText (scripts/BikeShop.asm) runs three ways: the BICYCLE is
-- already yours, you are carrying the BIKE VOUCHER, or you get the sales
-- pitch. The pitch draws its own window (TextBoxBorder hlcoord 0,0, b=4
-- c=15) holding BikeShopMenuText and BikeShopMenuPrice, and leaves it up
-- while the clerk keeps talking in the bottom box: the original never
-- erases it before TextScriptEnd (#568).
local BikeShopWindow = {}
BikeShopWindow.__index = BikeShopWindow
function BikeShopWindow.new(game, footer, onChoose)
local self = setmetatable({}, BikeShopWindow)
self.game = game
self.onChoose = onChoose
self.index = 1
self.active = true
-- BikeShopClerkDoYouLikeItText stays on screen under the window for as
-- long as the menu is up. The text box pushed on top types it out and
-- pops itself; this copy of its last page takes over from there, so the
-- bottom box never blanks between the pitch and the answer. Paginated
-- with the themed column budget so the copy breaks where the box did.
local TextBox = require("src.render.TextBox")
local box = require("src.ui.Theme").textBox or {}
local pages = TextBox.paginate(TextBox.substitute(game, footer), box.maxCols)
self.footer = pages[#pages]
return self
end
function BikeShopWindow:update()
-- one answer only: the boxes pushed by onChoose sit on top of this
-- state, but a second A on the same frame must not fire it twice
if not self.active then return end
local input = self.game.input
-- HandleMenuInput with wMenuWrappingEnabled clear: the two rows clamp
if input:wasPressed("up") then
self.index = 1
elseif input:wasPressed("down") then
self.index = 2
elseif input:wasPressed("a") or input:wasPressed("b") then
local cancelled = input:wasPressed("b") -- bit B_PAD_B -> .cancel
require("src.core.Sound").play(self.game.data, "Press_AB")
self.active = false
self.onChoose(not cancelled and self.index == 1)
end
end
function BikeShopWindow:draw()
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Theme = require("src.ui.Theme")
Font.drawBox(0, 0, 17, 6)
love.graphics.setColor(0, 0, 0, 1)
local bike = self.game.data.items.BICYCLE
Font.draw(bike and bike.name or "BICYCLE", 16, 16) -- hlcoord 2, 2
Font.draw("¥1000000", 64, 24) -- hlcoord 8, 3
Font.draw(Strings("CANCEL"), 16, 32) -- `next` skips a row
-- wTopMenuItemX 1, wTopMenuItemY 2, rows two apart
Font.drawCode(Theme.cursor, 8, self.index == 1 and 16 or 32)
if self.footer then
-- same geometry TextBox resolves against, so a themed box matches
local box = Theme.textBox or {}
local tx, ty = box.tx or 0, box.ty or 12
love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(tx, ty, box.tw or 20, box.th or 6)
love.graphics.setColor(0, 0, 0, 1)
for i, line in ipairs(self.footer) do
Font.draw(line, (tx + 1) * 8, (ty + 2 * i) * 8)
end
end
love.graphics.setColor(1, 1, 1, 1)
end
M.BIKE_SHOP = {
talk = {
TEXT_BIKESHOP_CLERK = function(game, ow, npc, done)
local Bag = require("src.inventory.Bag")
local Flags = require("src.script.Flags")
local TextBox = require("src.render.TextBox")
if (game.save.inventory.BICYCLE or 0) > 0 then
game.stack:push(TextBox.new(game, "How's the\nBICYCLE treating\nyou?", done))
elseif (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
game.save.inventory.BIKE_VOUCHER = nil
game.save.inventory.BICYCLE = 1
game.stack:push(TextBox.new(game,
("Oh, that's a\nBIKE VOUCHER!\f%s exchanged\nit for a BICYCLE!")
:format(game.save.player.name), done))
else
game.stack:push(TextBox.new(game,
"A BICYCLE costs\n¥1000000. Sorry,\nno instalments!", done))
local t = game.data.text
local function say(text, after, opts)
game.stack:push(TextBox.new(game, text, after, opts))
end
-- CheckEvent EVENT_GOT_BICYCLE. Saves made before the clerk started
-- setting the event still have the bike in the bag, so either counts.
if (game.save.inventory.BICYCLE or 0) > 0
or Flags.get(game.save, "EVENT_GOT_BICYCLE") then
say(t._BikeShopClerkHowDoYouLikeYourBicycleText, done)
return
end
-- .dontHaveBike: IsItemInBag BIKE_VOUCHER
if (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
say(t._BikeShopClerkOhThatsAVoucherText, function()
-- GiveItem's `jr nc, .BagFull`: the voucher is only spent once
-- the BICYCLE is actually in the bag
if not Bag.add(game.save, "BICYCLE", 1) then
say(t._BikeShopBagFullText, done)
return
end
Bag.remove(game.save, "BIKE_VOUCHER", 1)
Flags.set(game.save, "EVENT_GOT_BICYCLE")
-- BikeShopExchangedVoucherText carries sound_get_key_item; the
-- map runs EnableAutoTextBoxDrawing, so the box still waits for
-- a button once the jingle has played (auto.wait, #247)
say(t._BikeShopExchangedVoucherText, done, {
auto = { wait = true, sound = function()
return require("src.core.Sound").play(game.data, "Get_Key_Item")
end },
})
end)
return
end
-- .dontHaveVoucher: welcome, then the BICYCLE/CANCEL window
say(t._BikeShopClerkWelcomeText, function()
local pitch = t._BikeShopClerkDoYouLikeItText
game.stack:push(BikeShopWindow.new(game, pitch, function(bought)
local function comeAgain()
say(t._BikeShopComeAgainText, function()
game.stack:pop() -- the window, still up under the text
done()
end)
end
if bought then
-- a million is out of anyone's reach: BikeShopCantAffordText
say(t._BikeShopCantAffordText, comeAgain)
else
comeAgain()
end
end))
-- PrintText BikeShopClerkDoYouLikeItText, then straight into
-- HandleMenuInput: the box types out and hands over without
-- waiting, leaving the window's copy of the line on screen
say(pitch, nil, { auto = { delay = 0 } })
end)
end,
},
}
+72 -15
View File
@@ -111,8 +111,9 @@ M.POKEMON_TOWER_5F = {
--
-- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO
-- Silph Scope check at the trigger -- the scope only decides whether the
-- battle is disguised (IsGhostBattle -> makeGhost: "too scared to move",
-- balls dodged). An earlier version of this port turned the player back
-- disguise sticks (IsGhostBattle -> makeGhost: "too scared to move", balls
-- dodged) or comes off in the unveil (makeUnveiledGhost, #492). An earlier
-- version of this port turned the player back
-- without the scope and never opened the battle, which made 6F
-- impassable on any route that skips Rocket Hideout; vanilla lets the
-- battle open and a POKE_DOLL end it (see wBattleResult below).
@@ -133,7 +134,13 @@ M.POKEMON_TOWER_6F = {
-- or not the scope revealed it, so the "can't be caught" state rides
-- the battle instead of IsGhostBattle alone (#444)
battle.noCatch = true
if not game.save.inventory.SILPH_SCOPE then
-- InitWildBattle enters disguised for the RESTLESS SOUL either way
-- (core.asm:6698-6700). The scope does not skip the disguise, it
-- buys the unveil PrintBeginningBattleText .isMarowak plays over it
-- before the battle proceeds as an ordinary wild one (#492).
if game.save.inventory.SILPH_SCOPE then
battle:makeUnveiledGhost()
else
battle:makeGhost()
end
battle.onFinish = function(result)
@@ -353,6 +360,38 @@ M.ROCKET_HIDEOUT_B4F = {
end))
end,
-- Yellow swaps Rocket1/Rocket2 for Jessie & James and keeps a single
-- grunt, spelled ROCKETHIDEOUTB4F_ROCKET / TEXT_ROCKETHIDEOUTB4F_ROCKET
-- (pokeyellow/scripts/RocketHideoutB4F.asm), so the Red/Blue key above
-- never matched there and the LIFT KEY never dropped (#552, a
-- regression of #105). Yellow also moves the drop off the second
-- talk: RocketHideoutB4FRocketEndBattleText is text_promptbutton +
-- text_asm, so its SetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5 run the instant the battle ends.
TEXT_ROCKETHIDEOUTB4F_ROCKET = function(game, ow, npc, done)
if not ow:trainerDefeated(npc) then
ow:engageTrainer(npc, function()
-- engageTrainer records the win before it calls back, so this
-- is the end-battle text's SetEvent + ShowObject
if ow:trainerDefeated(npc)
and not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
local Commands = require("src.script.Commands")
Commands.show_object(
{ game = game, save = game.save, overworld = ow },
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
end
done()
end)
return
end
-- RocketHideoutB4FRocketAfterBattleText: later talks only reprint
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._RocketHideoutB4FRocketAfterBattleText
or "Oh no! I dropped\nthe LIFT KEY!", done))
end,
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
-- owns both the engage and the BeatGiovanniScript aftermath.
@@ -502,45 +541,56 @@ M.GAME_CORNER = {
end))
end)
end,
-- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a
-- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the
-- object const and from every one of his text labels
-- (GameCornerClerkText, pokeyellow/scripts/GameCorner.asm) with an
-- identical body, so each line resolves under both spellings and the
-- handler is bound to both text ids just below (#552).
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local t = game.data.text
local function line(suffix, fallback)
return t["_GameCornerClerk1" .. suffix]
or t["_GameCornerClerk" .. suffix]
or fallback
end
game.stack:push(TextBox.new(game,
(t._GameCornerClerk1DoYouNeedSomeGameCoinsText
or "Do you need some\ngame coins?\f¥1000 for 50."), function()
line("DoYouNeedSomeGameCoinsText",
"Do you need some\ngame coins?\f¥1000 for 50."), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1PleaseComePlaySometimeText
or "No? Please come\nplay sometime!", done))
line("PleaseComePlaySometimeText",
"No? Please come\nplay sometime!"), done))
return
end
-- scripts/GameCorner.asm GameCornerClerk1Text: coins need
-- the COIN CASE and room for at least 9 coins (Has9990Coins)
if not game.save.inventory.COIN_CASE then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1DontHaveCoinCaseText
or "You don't have a\nCOIN CASE!", done))
line("DontHaveCoinCaseText",
"You don't have a\nCOIN CASE!"), done))
return
end
if (game.save.coins or 0) >= 9990 then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1CoinCaseIsFullText
or "Oops! Your COIN\nCASE is full.", done))
line("CoinCaseIsFullText",
"Oops! Your COIN\nCASE is full."), done))
return
end
if game.save.money < 1000 then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1CantAffordTheCoinsText
or "You can't afford\nthe coins!", done))
line("CantAffordTheCoinsText",
"You can't afford\nthe coins!"), done))
return
end
game.save.money = game.save.money - 1000
game.save.coins = math.min(9999, (game.save.coins or 0) + 50)
game.stack:push(TextBox.new(game,
(t._GameCornerClerk1ThanksHereAre50CoinsText
or "Thanks! Here are\nyour 50 coins!")
line("ThanksHereAre50CoinsText",
"Thanks! Here are\nyour 50 coins!")
.. ("\fCOINS: %d"):format(game.save.coins), done))
end))
end))
@@ -548,6 +598,13 @@ M.GAME_CORNER = {
},
}
-- Yellow's coin clerk is GAMECORNER_CLERK / TEXT_GAMECORNER_CLERK where Red
-- and Blue spell him CLERK1, and the two text_asm bodies are identical, so
-- one handler answers both ids. Without the alias the Yellow clerk fell
-- through to his extracted offer line with no yes/no box behind it (#552).
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK =
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK1
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
-- The six mon prizes differ between Red and Blue; the three TM prizes are
-- identical, so they are shared and appended to each version's mon list.
+145
View File
@@ -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
+7
View File
@@ -65,6 +65,13 @@ display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
put the row back.
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
to AUTO, which reads this device as an ARM Linux handheld and resolves to
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
so the overworld runs smoothly on the H700 out of the box. Bump it to
BALANCED or HIGH from OPTIONS if you want the extras and your device keeps
up; see [Performance tier](new-features.md#performance-tier-low-end-devices).
The pack bundles the LÖVE 11.5 aarch64 runtime from
[PortMaster](https://portmaster.games/), so the device does not need a
separate `love_11.5` runtime download on first launch. The launcher resolves
+5 -1
View File
@@ -12,7 +12,7 @@ this port.
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here |
|---|---|---|---|---|
| 1 | Bag capacity | 20 item slots | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `src/inventory/Bag.lua:8` (`Bag.CAPACITY = 20`) |
| 1 | Bag capacity | 20 item slots by default | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `Data.constants.bagSize`, read by `src/inventory/Bag.lua` (`Bag.capacity`) |
| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) |
| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` |
| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` |
@@ -38,6 +38,10 @@ this port.
## Notes
- Mods may patch `constants.bagSize` through the public content registry. The
native `save.lua` format keeps every existing item when the configured
limit changes; exporting to a cartridge `.sav` still writes only the first
20 bag slots because the original SRAM layout has no room for more.
- PC Box **overflow handling** was deliberately changed even though the
20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards
or blocks the deposit," this port spills into the next box with room.
+13
View File
@@ -187,5 +187,18 @@ composited and before touch controls draw. The window-space viewport contains
and `dpiY`, so a tool can use the letterbox margins without drawing over the
playfield or pushing an updating game state.
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
the mod full control of the window, while calling `next` runs the engine's
normal single-window composite so the mod can decorate around it. `ctx` carries
the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
palette-correct blit of either canvas into an arbitrary screen rect, and the
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`)
for driving a second physical display. This is what lets a mod lay the two
passes out as two stacked Game Boy screens, or push one onto a second screen,
without the engine knowing the layout.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
+65 -2
View File
@@ -143,6 +143,41 @@ effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
its launcher because the device reports `"Linux"` while its GPU is in the
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
## Performance tier (low-end devices)
The Options **PERFORMANCE** row scales the port's optional presentation
extras down for weaker hardware. The extras it governs are the three
heaviest things the port adds on top of the original -- the whole-screen 3D
**TILT** (transforms the entire map as a ground plane), the **GBC FX**
post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out
renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard
FPS ceiling. None of this touches game logic, which is fixed-step off `dt`
(`src/core/FixedStep.lua`), so every tier plays identically; they differ
only in how much eye-candy the renderer is allowed to do.
| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling |
| ------------ | ---- | ------ | ----------- | ----------------- |
| **HIGH** | on | on | on | none |
| **BALANCED** | off | off | on | none |
| **LOW** | off | off | off | 60 |
| **AUTO** | picks a default from the device (below) |||
- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds
(e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core
desktops to **BALANCED**, and everything else -- a normal desktop, and
every existing `options.lua` that predates this option -- to **HIGH**,
so the common case is unchanged. See `src/core/Performance.detect`.
- AUTO only chooses the *default*; all four tiers are selectable, so a
wrong guess is one row away from being overridden.
- The clamps are applied **live** against your stored options and never
rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC
FX / ZOOM without forgetting them -- raising the tier restores exactly
what you had. (This is why the TILT / GBC FX / ZOOM rows still show your
saved choice on a clamped tier: it's your preference, waiting for a tier
that can afford it.)
- Persisted as `save.options.performance` (`auto` | `high` | `balanced` |
`low`); unit-tested in `tests/engine/performance_tiers.lua`.
## Peer-to-peer link play (lua-enet)
Trades and link battles connect two copies of the game directly over
@@ -280,7 +315,7 @@ 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
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
@@ -345,7 +380,8 @@ semantics - so the two windows read as one app. Six tabs:
party dock, so deposit and withdraw live in one place. Empty slots are
clickable and create a mon there.
- **Items**: money, a searchable item picker (replacing the arrows that
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
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.
- **Events**: flags, defeated trainers, taken items and per-map object
toggles, with a real filter field and a two-column paged grid.
@@ -397,3 +433,30 @@ own repositories. No index ships with the launcher and none is ever added
automatically, so the tab opens on an "Add an index" prompt until you name
one; paste an index URL or its `owner/repo` and it is remembered in
`options.lua`. More than one index can be added, and the listings merge.
## Soft reset (all versions)
Holding A, B, START and SELECT together restarts the game the way flicking
a Game Boy's power switch did, dropping straight back to the title screen.
It works from anywhere, including mid-battle, which the QUIT entry on the
start menu cannot do: the original combo is how stationary and gift
Pokemon get their stats rerolled without sitting through a full relaunch.
Unsaved progress is discarded, exactly as on hardware.
As on the original, the four buttons have to stay held for 16 straight
polls (better than a quarter of a second) and any direction in the mix
cancels it, so it is hard to hit by accident -- including on the on-screen
touch controls, where it would take four fingers held on four separate
controls.
## Controls rebinding (CONTROLS screen)
OPTIONS -> CONTROLS lists every Game Boy button with its current keyboard
key and controller button side by side (Z/A). Press A on a row, then press
and release the key or pad button you want; the rebind commits on the
release. If that input already belongs to another row, the two rows swap,
so no button is ever stranded without an input and no input ever serves
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.
+382
View File
@@ -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 |
| --- | --- | --- | --- | --- | --- |
| `<CONT>` 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** |
| `<PARA>` 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 |
| `<CONT>` pre-input hold | **3** | `home/text.asm:263-267` | 0 | 3 |
| `<CONT>` 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.
+19 -2
View File
@@ -461,14 +461,31 @@ function love.wheelmoved(x, y)
Game:wheelmoved(x, y)
end
function love.mousepressed(x, y, button)
function love.mousepressed(x, y, button, istouch)
if TouchEditor then
-- Android primary touch already arrived via love.touchpressed; a second
-- mouse path would double-fire Done / begin a second drag.
if love.system.getOS() == "Android" then return end
return TouchEditor.mousepressed(x, y, button)
end
if Importer then return Importer:mousepressed(x, y, button) end
if Importer then
-- The same double-fire TouchEditor guards against, which the launcher was
-- missing: love.touchpressed above forwards the primary touch to the
-- Importer on Android, and LÖVE ALSO synthesizes a mouse press for that
-- same touch, so one tap ran every launcher button twice. On Import that
-- meant two choose() calls and two stacked SAF picker activities: the
-- player picked their ROM, the top picker closed, and the second was still
-- underneath asking for it again, which is the "import the file twice"
-- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a
-- USB mouse) working, which an Android-wide return would have broken.
--
-- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above
-- returns early on iOS and never forwards, so there the synthesized mouse
-- press is the ONLY event the launcher gets. Filtering istouch on both
-- killed every tap on iOS outright.
if istouch and love.system.getOS() == "Android" then return end
return Importer:mousepressed(x, y, button)
end
if editorMode and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, button)
end
+23
View File
@@ -50,6 +50,29 @@ chosen file under the app save directory as `picked_rom.gb`,
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
itself remains data-free (no embedded ROM or generated cache).
### Step bridge (Pokéwalker mod)
`love.system.syncHealthSteps()``GameActivity.syncHealthSteps` (same
JNI route as the picker: `common/android.cpp`
`modules/system/System.cpp``wrap_System.cpp`). The Java side does a
one-shot read of the hardware `TYPE_STEP_COUNTER` sensor (cumulative
since boot, counted by the OS whether or not any app runs), anchors the
reading in `SharedPreferences` so a walk is never credited twice
(a reading below the anchor means the phone rebooted → re-anchor without
crediting), and stages the delta as `steps_pending.json` in the save
identity dir — the same contract as the iOS `GRHealthBridge`. Nothing in
the base game calls it; the consumer is the
[Pokéwalker mod](https://github.com/mresnick67/Gen1ReComp-Pokewalker),
installed as a mod `.zip` at runtime (its SYNC STEPS option defaults
off).
Android 10+ gates the sensor behind the `ACTIVITY_RECOGNITION` runtime
permission (declared in `app/src/main/AndroidManifest.xml`; keep it out
of the build script's permission trim). The first
`syncHealthSteps()` call shows the system prompt; on grant the sensor
read runs immediately (`onRequestPermissionsResult`,
`STEP_PERMISSION_REQUEST_CODE`).
### SDK / NDK
love-android 11.5a expects:
@@ -8,6 +8,11 @@
the link screen shows as "(Operation not permitted)" (issue #287).
scripts/build_android.sh must not strip this again. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
counter, which Android 10+ gates behind this runtime permission.
Requested only on the first sync call (the Pokéwalker mod's SYNC
STEPS option); scripts/build_android.sh must not strip it. -->
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<!-- OpenGL ES 2.0 -->
<uses-feature android:glEsVersion="0x00020000" />
<!-- Touchscreen support -->
@@ -219,6 +219,40 @@ bool showCreateDocument(const char *suggestedName)
return result;
}
bool syncHealthSteps()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "syncHealthSteps", "()Z");
jboolean result = env->CallStaticBooleanMethod(activity, method);
env->DeleteLocalRef(activity);
return result;
}
bool restartApp()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
// Old APK / new liblove skew: fail soft so HostShell.restart can fall
// back to a clean quit instead of aborting on a missing method (#575).
jmethodID method = env->GetStaticMethodID(activity, "restartApp", "()Z");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
// Does not return on success: the Java side exits the process.
jboolean result = env->CallStaticBooleanMethod(activity, method);
env->DeleteLocalRef(activity);
return result;
}
/*
* Helper functions for the filesystem module
*/
@@ -851,4 +885,51 @@ const char *getArg0()
} // android
} // love
extern "C" __attribute__((visibility("default")))
int love_android_secondary_ready()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID m = env->GetStaticMethodID(activity, "hasSecondaryDisplay", "()Z");
jboolean ready = JNI_FALSE;
if (m)
ready = env->CallStaticBooleanMethod(activity, m);
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
return ready ? 1 : 0;
}
extern "C" __attribute__((visibility("default")))
void love_android_push_secondary(const void *rgba, int w, int h)
{
if (!rgba || w <= 0 || h <= 0)
return;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID m = env->GetStaticMethodID(activity, "updateSecondaryFrame", "(Ljava/nio/ByteBuffer;II)V");
if (m)
{
jobject buf = env->NewDirectByteBuffer((void*) rgba, (jlong) w * (jlong) h * 4);
env->CallStaticVoidMethod(activity, m, buf, w, h);
env->DeleteLocalRef(buf);
}
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
}
extern "C" __attribute__((visibility("default")))
void love_android_secondary_enable(int on)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID m = env->GetStaticMethodID(activity, "setSecondaryEnabled", "(Z)V");
if (m)
env->CallStaticVoidMethod(activity, m, on ? JNI_TRUE : JNI_FALSE);
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
}
#endif // LOVE_ANDROID
@@ -74,6 +74,22 @@ bool showFilePicker(const char *destFilename = nullptr);
**/
bool showCreateDocument(const char *suggestedName = nullptr);
/**
* Pokéwalker step bridge: asks GameActivity to read the hardware step
* counter and stage steps_pending.json in the save identity dir (see
* GameActivity.syncHealthSteps). Returns whether a sync could start.
*/
bool syncHealthSteps();
/**
* Full process relaunch (GameActivity.restartApp): schedules the app's
* launch intent and kills the process, because the in-process
* quit("restart") loop double-inits physfs and crashes (#575). On success
* the process dies inside the Java call and this never returns; false
* means the relaunch could not be scheduled.
**/
bool restartApp();
/*
* Helper functions for the filesystem module
*/
@@ -212,6 +212,24 @@ bool System::createFile(const char *suggestedName) const
#endif
}
bool System::syncHealthSteps() const
{
#ifdef LOVE_ANDROID
return love::android::syncHealthSteps();
#else
return false;
#endif
}
bool System::restartApp() const
{
#ifdef LOVE_ANDROID
return love::android::restartApp();
#else
return false;
#endif
}
bool System::hasBackgroundMusic() const
{
#if defined(LOVE_ANDROID)
@@ -127,6 +127,20 @@ public:
**/
virtual bool createFile(const char *suggestedName = nullptr) const;
/**
* Pokéwalker: stage pending real-world steps (steps_pending.json in the
* save dir) from the platform step source. Android-only; false elsewhere.
*/
virtual bool syncHealthSteps() const;
/**
* Relaunches the whole app with a fresh process (Android only; false
* elsewhere). The in-process love.event.quit("restart") double-inits
* physfs on Android and crashes, so src/core/HostShell.lua calls this
* instead (#575). Does not return on success -- the process exits.
**/
virtual bool restartApp() const;
/**
* Gets if the user is playing music on background.
* Throws an exception on unsupported platforms.
@@ -109,6 +109,20 @@ int w_createFile(lua_State *L)
return 1;
}
int w_syncHealthSteps(lua_State *L)
{
luax_pushboolean(L, instance()->syncHealthSteps());
return 1;
}
int w_restartApp(lua_State *L)
{
// Does not return on success: GameActivity.restartApp exits the process
// after scheduling the relaunch (#575).
luax_pushboolean(L, instance()->restartApp());
return 1;
}
int w_hasBackgroundMusic(lua_State *L)
{
lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -126,6 +140,8 @@ static const luaL_Reg functions[] =
{ "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp },
{ "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 }
};
@@ -37,17 +37,26 @@ import java.util.List;
import java.util.Map;
import android.Manifest;
import android.app.AlarmManager;
import android.app.AlertDialog;
import android.app.PendingIntent;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.content.res.AssetManager;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.os.Vibrator;
import android.util.Log;
import android.util.DisplayMetrics;
@@ -67,6 +76,8 @@ public class GameActivity extends SDLActivity {
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
public static final int FILE_PICKER_REQUEST_CODE = 4;
public static final int FILE_CREATE_REQUEST_CODE = 5;
public static final int STEP_PERMISSION_REQUEST_CODE = 6;
public static final int RESTART_REQUEST_CODE = 7;
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
@@ -83,8 +94,27 @@ public class GameActivity extends SDLActivity {
// basename as its body, so RomImporter:focus can say so in the launcher
// instead of leaving the player on "No ROM imported" (issue #442).
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
// Step bridge (love.system.syncHealthSteps): pending-steps delivery
// consumed by the Pokéwalker mod, same contract as the iOS
// GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
// (cumulative since boot, counted by the OS whether or not any app is
// running), anchored in SharedPreferences so a walk is never credited
// twice.
private static final String PENDING_STEPS_FILENAME = "steps_pending.json";
private static final String STEP_PREFS = "pokewalker_steps";
private static final String STEP_PREF_ANCHOR = "anchor";
private static final String STEP_PREF_ANCHOR_WALLTIME = "anchor_walltime";
private static final long STEP_MAX_PER_SYNC = 50000;
// Destination basename for the in-flight SAF pick (set by showFilePicker).
// Saved/restored across instance state: the picker is a separate activity
// and Android may destroy this one while it is up (memory pressure, or
// "Don't keep activities"). A recreated instance still receives
// onActivityResult, so without this a mod or save pick came back with the
// field reset and was filed as picked_rom.gb, which Lua then rejected as a
// bad ROM instead of installing it (#553).
private String pendingPickFilename = PICKED_ROM_FILENAME;
private static final String STATE_PENDING_PICK = "pendingPickFilename";
private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName";
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
private String pendingCreateSuggestedName = "export.sav";
private static boolean immersiveActive = false;
@@ -149,6 +179,14 @@ public class GameActivity extends SDLActivity {
}
super.onCreate(savedInstanceState);
if (savedInstanceState != null) {
// Restore the in-flight SAF destinations, so a pick that returns to
// a recreated activity still lands under the basename it asked for.
String pick = savedInstanceState.getString(STATE_PENDING_PICK);
if (pick != null) pendingPickFilename = pick;
String create = savedInstanceState.getString(STATE_PENDING_CREATE);
if (create != null) pendingCreateSuggestedName = create;
}
metrics = getResources().getDisplayMetrics();
// Set low-latency audio values
@@ -296,12 +334,14 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel();
}
teardownSecondaryDisplay();
super.onPause();
}
@Override
public void onResume() {
super.onResume();
setupSecondaryDisplay();
}
/**
@@ -410,15 +450,23 @@ public class GameActivity extends SDLActivity {
* Shows the system document picker (Storage Access Framework) so the
* player can pick a ROM / mod / save from anywhere (Downloads, Drive,
* etc.) without needing to know where the app's external files folder
* is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
* arrives later in onActivityResult, not synchronously here.
* is. The picked file (if any) arrives later in onActivityResult, not
* synchronously here.
*
* API 21+ uses ACTION_OPEN_DOCUMENT; API 16-20 uses an ACTION_GET_CONTENT
* chooser instead. Below 19 OPEN_DOCUMENT does not exist, and on 19/20
* the stock DocumentsUI is unreliable -- it launches and then hands back
* RESULT_CANCELED with no data, which onActivityResult cannot tell apart
* from the player cancelling (#584). GET_CONTENT lets any installed file
* manager serve the pick, and both intents return the same content:// or
* file:// URI shapes, so the result path in onActivityResult stays
* picker-agnostic and unchanged.
*
* @param destFilename basename under the app save identity (e.g.
* picked_rom.gb, picked_mod.zip, picked_save.sav)
*/
@Keep
public static boolean showFilePicker(String destFilename) {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (destFilename == null || destFilename.length() == 0) {
@@ -432,11 +480,26 @@ public class GameActivity extends SDLActivity {
}
self.pendingPickFilename = destFilename;
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
if (android.os.Build.VERSION.SDK_INT >= 21) {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
try {
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
return true;
} catch (Exception e) {
// Some OEM / TV builds ship without DocumentsUI; fall through
// to the GET_CONTENT chooser below instead of giving up (#584).
Log.d("GameActivity", "could not open document picker: " + e.getMessage());
}
}
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
try {
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
self.startActivityForResult(
Intent.createChooser(intent, "Choose a file"),
FILE_PICKER_REQUEST_CODE);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open file picker: " + e.getMessage());
@@ -462,14 +525,60 @@ public class GameActivity extends SDLActivity {
return showFilePicker(PICKED_SAVE_FILENAME);
}
/**
* Relaunches the whole app for love.system.restartApp, used by
* src/core/HostShell.lua when a mod toggle needs a cold boot (#575).
* love.event.quit("restart") re-runs LOVE's boot inside the same
* process, and the second love.filesystem.init throws once physfs
* failed to deinit ("already initialized"), killing the app. Instead
* we hand our launch intent to AlarmManager and then exit the process:
* the alarm lives in system_server, so it survives our death and
* cannot race the exit the way a plain startActivity right before
* Runtime.exit can on some OEMs, and the dead process guarantees no
* native (physfs / SDL / JNI) state leaks into the fresh run.
*/
@Keep
public static boolean restartApp() {
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
try {
Context context = self.getApplicationContext();
Intent intent = context.getPackageManager()
.getLaunchIntentForPackage(context.getPackageName());
if (intent == null) return false;
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
int pendingFlags = PendingIntent.FLAG_CANCEL_CURRENT;
if (android.os.Build.VERSION.SDK_INT >= 23) {
// Mandatory mutability flag on API 31+; harmless from 23 up.
pendingFlags |= PendingIntent.FLAG_IMMUTABLE;
}
PendingIntent pending = PendingIntent.getActivity(
context, RESTART_REQUEST_CODE, intent, pendingFlags);
AlarmManager alarm = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
if (alarm == null) return false;
alarm.set(AlarmManager.RTC, System.currentTimeMillis() + 250, pending);
} catch (Exception e) {
Log.d("GameActivity", "could not schedule restart: " + e.getMessage());
return false;
}
Runtime.getRuntime().exit(0);
return true; // unreachable, but keeps the JNI signature honest
}
/**
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive /
* etc. Suggested name is the dialog's default filename.
*
* CREATE_DOCUMENT does not exist below API 19 and has no pre-SAF
* equivalent, so unlike showFilePicker this stays 19+ (#584); the false
* return degrades on the Lua side (RomImporter export) to "Exported
* inside the app folder", which is the correct pre-KitKat behavior.
*/
@Keep
public static boolean showCreateDocument(String suggestedName) {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
// (see showFilePicker for why the import side got a pre-19 path)
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (suggestedName == null || suggestedName.length() == 0) {
@@ -516,6 +625,160 @@ public class GameActivity extends SDLActivity {
}
}
/**
* Step sync, called from Lua as love.system.syncHealthSteps()
* (see modules/system/wrap_System.cpp). Asynchronous like the picker:
* returns whether a sync could be started; the result lands later as
* steps_pending.json in the save identity dir, where the Pokéwalker
* mod's poll consumes it.
*
* Android 10+ gates the step counter behind the ACTIVITY_RECOGNITION
* runtime permission; the first call shows the system prompt and a later
* sync (the mod retries on save load / option change) delivers.
*/
@Keep
public static boolean syncHealthSteps() {
final GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (android.os.Build.VERSION.SDK_INT >= 29
&& ActivityCompat.checkSelfPermission(self,
Manifest.permission.ACTIVITY_RECOGNITION)
!= PackageManager.PERMISSION_GRANTED) {
self.runOnUiThread(new Runnable() {
@Override
public void run() {
ActivityCompat.requestPermissions(self,
new String[]{Manifest.permission.ACTIVITY_RECOGNITION},
STEP_PERMISSION_REQUEST_CODE);
}
});
return true;
}
self.startStepSensorRead();
return true;
}
/**
* One-shot read of the cumulative hardware step counter. The sensor
* usually reports its cached value moments after registration; some
* devices hold the event until the next physical step, so the listener
* is given 20 seconds before being torn down (the next sync retries).
*/
private void startStepSensorRead() {
final SensorManager manager =
(SensorManager) getSystemService(Context.SENSOR_SERVICE);
if (manager == null) return;
Sensor counter = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER);
if (counter == null) {
Log.d("GameActivity", "no step counter sensor on this device");
return;
}
final SensorEventListener listener = new SensorEventListener() {
private boolean delivered = false;
@Override
public void onSensorChanged(SensorEvent event) {
if (delivered || event.values.length == 0) return;
delivered = true;
manager.unregisterListener(this);
deliverSteps((long) event.values[0]);
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
}
};
if (!manager.registerListener(listener, counter,
SensorManager.SENSOR_DELAY_NORMAL)) {
Log.d("GameActivity", "step counter listener registration failed");
return;
}
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
@Override
public void run() {
// No-op if the listener already delivered and unregistered.
manager.unregisterListener(listener);
}
}, 20000);
}
/**
* Convert a cumulative counter reading into pending steps. The counter
* resets to zero on reboot: a reading below the stored anchor re-anchors
* without crediting (steps walked between the reboot and this sync are
* lost, which errs on the honest side).
*/
private void deliverSteps(long counterNow) {
SharedPreferences prefs = getSharedPreferences(STEP_PREFS, MODE_PRIVATE);
long anchor = prefs.getLong(STEP_PREF_ANCHOR, -1);
long now = System.currentTimeMillis();
if (anchor < 0 || counterNow < anchor) {
prefs.edit()
.putLong(STEP_PREF_ANCHOR, counterNow)
.putLong(STEP_PREF_ANCHOR_WALLTIME, now)
.apply();
Log.d("GameActivity", "step anchor set at " + counterNow);
return;
}
long steps = Math.min(counterNow - anchor, STEP_MAX_PER_SYNC);
long fromWalltime = prefs.getLong(STEP_PREF_ANCHOR_WALLTIME, now);
if (steps <= 0) return;
prefs.edit()
.putLong(STEP_PREF_ANCHOR, counterNow)
.putLong(STEP_PREF_ANCHOR_WALLTIME, now)
.apply();
File dir = saveIdentityDir();
if (!dir.isDirectory() && !dir.mkdirs()) {
Log.d("GameActivity", "cannot create save dir for steps: " + dir);
return;
}
File pending = new File(dir, PENDING_STEPS_FILENAME);
long total = steps;
// Merge with an unconsumed earlier delivery so steps are never lost
// (same contract as the iOS bridge).
if (pending.isFile()) {
try {
byte[] raw = new byte[(int) Math.min(pending.length(), 4096)];
FileInputStream in = new FileInputStream(pending);
int read = in.read(raw);
in.close();
if (read > 0) {
org.json.JSONObject old =
new org.json.JSONObject(new String(raw, 0, read, "UTF-8"));
total += Math.max(0, old.optLong("steps", 0));
}
} catch (Exception e) {
Log.d("GameActivity", "ignoring unreadable pending steps: " + e);
}
}
try {
org.json.JSONObject payload = new org.json.JSONObject();
payload.put("steps", total);
payload.put("from", isoTime(fromWalltime));
payload.put("to", isoTime(now));
File tmp = new File(dir, PENDING_STEPS_FILENAME + ".tmp");
FileOutputStream out = new FileOutputStream(tmp);
out.write(payload.toString().getBytes("UTF-8"));
out.close();
if (!tmp.renameTo(pending)) {
tmp.delete();
Log.d("GameActivity", "could not publish pending steps");
return;
}
Log.d("GameActivity", total + " steps pending for the Pokewalker mod");
} catch (Exception e) {
Log.d("GameActivity", "could not write pending steps: " + e);
}
}
private static String isoTime(long millis) {
java.text.SimpleDateFormat format =
new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US);
format.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
return format.format(new java.util.Date(millis));
}
private boolean copyFileToUri(File source, Uri destUri) {
InputStream in = null;
OutputStream out = null;
@@ -539,6 +802,13 @@ public class GameActivity extends SDLActivity {
}
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putString(STATE_PENDING_PICK, pendingPickFilename);
outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
@@ -739,6 +1009,17 @@ public class GameActivity extends SDLActivity {
}
break;
}
case STEP_PERMISSION_REQUEST_CODE: {
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Log.d("GameActivity", "Step permission granted");
// Deliver right away so the sync the player just
// opted into doesn't wait for the next launch.
startStepSensorRead();
} else {
Log.d("GameActivity", "Did not get step permission.");
}
break;
}
default:
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
@@ -921,4 +1202,182 @@ public class GameActivity extends SDLActivity {
return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so";
}
}
// Dual-screen: mirror the engine's bottom-screen canvas onto a secondary
// physical display. Driven from the engine through love_android_secondary_*
// in src/jni/love/src/common/android.cpp.
private static volatile SecondaryPresentation secondaryPresentation;
private static volatile boolean secondaryEnabled = false;
@Keep
public static void setSecondaryEnabled(final boolean on) {
secondaryEnabled = on;
final GameActivity self = (GameActivity) mSingleton;
if (self == null) return;
self.runOnUiThread(new Runnable() {
@Override public void run() {
if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay();
}
});
}
private static void setupSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
try {
android.hardware.display.DisplayManager dm =
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
if (dm == null) return;
Display chosen = null;
for (Display d : dm.getDisplays()) {
android.graphics.Point size = new android.graphics.Point();
d.getRealSize(size);
Log.d("GameActivity", "display id=" + d.getDisplayId() + " name=" + d.getName()
+ " size=" + size.x + "x" + size.y);
if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) {
chosen = d;
}
}
if (chosen == null) {
Display[] pres =
dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
if (pres != null && pres.length > 0) chosen = pres[0];
}
if (chosen == null) {
Log.d("GameActivity", "no secondary display found");
return;
}
SecondaryPresentation p = new SecondaryPresentation(self, chosen);
p.show();
secondaryPresentation = p;
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
} catch (Throwable t) {
Log.d("GameActivity", "secondary display setup failed: " + t);
secondaryPresentation = null;
}
}
private static void teardownSecondaryDisplay() {
SecondaryPresentation p = secondaryPresentation;
secondaryPresentation = null;
if (p != null) {
try { p.dismiss(); } catch (Throwable t) {}
}
}
@Keep
public static boolean hasSecondaryDisplay() {
return secondaryPresentation != null;
}
@Keep
public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) {
SecondaryPresentation p = secondaryPresentation;
if (p != null && buf != null && w > 0 && h > 0) {
p.updateFrame(buf, w, h);
}
}
private static class SecondaryPresentation extends android.app.Presentation {
private final FrameView frameView;
SecondaryPresentation(Context context, Display display) {
super(context, display);
frameView = new FrameView(context);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
android.view.Window w = getWindow();
if (w != null) {
w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
w.setLayout(WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT);
}
setContentView(frameView);
applyImmersive();
frameView.post(new Runnable() {
@Override public void run() { applyImmersive(); }
});
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) applyImmersive();
}
private void applyImmersive() {
android.view.Window w = getWindow();
if (w == null) return;
if (android.os.Build.VERSION.SDK_INT >= 30) {
w.setDecorFitsSystemWindows(false);
android.view.WindowInsetsController c = w.getInsetsController();
if (c != null) {
c.hide(android.view.WindowInsets.Type.systemBars());
c.setSystemBarsBehavior(
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
} else {
w.getDecorView().setSystemUiVisibility(
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
}
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
frameView.updateFrame(buf, w, h);
}
}
private static class FrameView extends View {
private android.graphics.Bitmap bitmap;
private final android.graphics.Rect dst = new android.graphics.Rect();
private final android.graphics.Paint paint = new android.graphics.Paint();
private final Object lock = new Object();
private int fw, fh;
FrameView(Context context) {
super(context);
paint.setFilterBitmap(false);
paint.setAntiAlias(false);
setBackgroundColor(0xFF000000);
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
synchronized (lock) {
if (bitmap == null || fw != w || fh != h) {
if (bitmap != null) bitmap.recycle();
bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888);
fw = w; fh = h;
}
buf.rewind();
bitmap.copyPixelsFromBuffer(buf);
}
postInvalidate();
}
@Override
protected void onDraw(android.graphics.Canvas canvas) {
synchronized (lock) {
if (bitmap == null || fw == 0 || fh == 0) return;
int vw = getWidth(), vh = getHeight();
int s = Math.min(vw / fw, vh / fh);
if (s < 1) s = 1;
int dw = fw * s, dh = fh * s;
int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
dst.set(dx, dy, dx + dw, dy + dh);
canvas.drawColor(0xFF000000);
canvas.drawBitmap(bitmap, null, dst, paint);
}
}
}
}
+1 -1
View File
@@ -1 +1 @@
11.5
12.0
+17 -38
View File
@@ -1,4 +1,4 @@
# iOS build (LÖVE 11.5)
# iOS build (LÖVE 12.0)
> **Native ROM/mod/save import.** The iOS build ships a Swift
> document-picker bridge (`native/GRPickerBridge.swift` + `GRBootstrap.m`)
@@ -20,20 +20,17 @@
> The note below about a missing "UIDocumentPicker handoff" is
> resolved by this bridge.
macOS + Xcode only. Pins the official **LÖVE 11.5** iOS Xcode tree
(`love-11.5-ios-source.zip` from [love2d/love releases](https://github.com/love2d/love/releases/tag/11.5)),
matching `conf.lua`'s `t.version = "11.5"`.
macOS + Xcode only. Fetches the **LÖVE 12.0** source tree and matching Apple
dependencies from the official [LÖVE source](https://github.com/love2d/love)
and [Apple dependencies](https://github.com/love2d/love-apple-dependencies)
repositories. `conf.lua` declares LÖVE 12.0 on iOS and 11.5 elsewhere.
There is no separate `love2d/love-ios` GitHub repo for 11.5; the release zip
**is** the vendored iOS project (Xcode project under
`love-src/platform/xcode/love.xcodeproj`, target `love-ios`).
Pin file: [`LOVE_VERSION`](./LOVE_VERSION) → `11.5`.
Pin file: [`LOVE_VERSION`](./LOVE_VERSION) → `12.0`.
## Quick start (simulator)
```bash
# Fetch LÖVE 11.5 iOS sources (once) + build for Simulator
# Fetch LÖVE 12.0 iOS sources and dependencies (once) + build for Simulator
scripts/build_ios.sh --fetch
```
@@ -80,10 +77,10 @@ Manual out-of-band steps:
| Path | Role |
|------|------|
| `LOVE_VERSION` | Engine pin (`11.5`) |
| `LOVE_VERSION` | Engine pin (`12.0`) |
| `overlays/love-ios.plist` | Portrait-only Info.plist + display name **Pokemon Red** (copied over the upstream plist every build) |
| `love-src/` | Downloaded `love-11.5-ios-source` tree (**gitignored**, do not commit) |
| `cache/` | Downloaded zips (**gitignored**) |
| `love-src/` | Downloaded LÖVE 12.0 source tree (**gitignored**, do not commit) |
| `cache/` | Temporary source and dependency checkout data (**gitignored**) |
| `build/` | `xcodebuild` derived data (**gitignored**) |
Game payload lands at:
@@ -94,28 +91,10 @@ and is fused into the built `.app` (LÖVE auto-runs any bundled `*.love`).
## Apple libraries dependency
The official `love-11.5-ios-source.zip` already ships prebuilt iOS
xcframeworks under `platform/xcode/ios/libraries/` (SDL2, LuaJIT, freetype,
ogg, vorbis, theora, modplug).
If that folder is missing or incomplete (e.g. you cloned sources without
libs), download the matching prebuilts and install them:
```bash
curl -fL -o mobile/ios/cache/love-11.5-apple-libraries.zip \
https://github.com/love2d/love/releases/download/11.5/love-11.5-apple-libraries.zip
unzip -q mobile/ios/cache/love-11.5-apple-libraries.zip -d mobile/ios/cache
rm -rf mobile/ios/love-src/platform/xcode/ios/libraries
cp -R mobile/ios/cache/love-apple-dependencies/iOS/libraries \
mobile/ios/love-src/platform/xcode/ios/libraries
```
`scripts/build_ios.sh` checks for `libraries/SDL2.xcframework` and fails with
these instructions if it is absent.
Upstream also documents
[love-apple-dependencies](https://github.com/love2d/love-apple-dependencies)
as an alternate source of the same libraries.
`scripts/build_ios.sh --fetch` retrieves the matching iOS libraries and the
SDL3 framework from
[love-apple-dependencies](https://github.com/love2d/love-apple-dependencies).
Re-run it if either dependency directory is absent.
## App identity
@@ -134,7 +113,7 @@ so refreshing `love-src/` does not lose branding.
| Flag | Meaning |
|------|---------|
| *(default)* | Simulator, Debug, no signing |
| `--fetch` | Download/extract `love-11.5-ios-source.zip` if `love-src/` is missing |
| `--fetch` | Fetch the LÖVE 12.0 source tree and Apple dependencies if `love-src/` is missing |
| `--device` | Build against `iphoneos` instead of `iphonesimulator` |
| `--release` | `Release` configuration instead of `Debug` |
| `--package-only` | Zip `game.love` + apply plist overlay; skip `xcodebuild` |
@@ -147,5 +126,5 @@ Also: `scripts/build.sh ios` delegates here (`--release` is forwarded).
- iOS platform installed in Xcode (Settings → Platforms). `xcodebuild -showsdks`
should list `iphonesimulator` / `iphoneos`. A partial install can fail IB/xib
compiles with `iOS … Platform Not Installed` even when the SDK name appears.
- `love-src/` present (`--fetch` or manual unzip of `love-11.5-ios-source.zip`)
- iOS libraries under `love-src/platform/xcode/ios/libraries/` (see above)
- `love-src/` present (`--fetch`)
- iOS libraries under `love-src/platform/xcode/ios/libraries/` and SDL3 under `love-src/platform/xcode/shared/Frameworks/`
+1 -5
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Pokemon Red</string>
<string>gen1recomp</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
@@ -49,10 +49,6 @@
<false/>
<key>UILaunchStoryboardName</key>
<string>Launch Screen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>opengles-2</string>
</array>
<key>UIStatusBarHidden</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
+7 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Applies gen1recomp's iOS native-bridge patches to the fetched LÖVE 11.5
"""Applies gen1recomp's iOS native-bridge patches to the fetched LÖVE 12.0
source tree (mobile/ios/love-src/). Idempotent AND re-appliable: the first
run stashes a pristine `.orig` copy of every file it rewrites, and later
runs always start over from that copy so editing the patch content here
@@ -39,6 +39,7 @@ WRAP_INCLUDES = """
#ifdef LOVE_IOS
#include <objc/runtime.h>
#include <objc/message.h>
#include <string>
#include "filesystem/Filesystem.h"
#endif
""" % MARKER
@@ -53,8 +54,12 @@ WRAP_FUNCS = """
#ifdef LOVE_IOS
static const char *gr_saveDirectory()
{
static std::string saveDirectory;
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
return fs != nullptr ? fs->getSaveDirectory() : "";
if (fs == nullptr)
return "";
saveDirectory = fs->getSaveDirectory();
return saveDirectory.c_str();
}
static int gr_callBridge(lua_State *L, const char *className,
+1 -1
View File
@@ -156,7 +156,7 @@ return function(mod)
caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST"
mod.save:set("caught_areas", caughtAreas())
end
Bag.add(self.game.save, ball, 1)
Bag.add(self.game.save, ball, 1, self.game.data)
self:say(reason == "area" and "This area already\nhas a captured POKéMON!"
or "You already have\nthis POKéMON family!")
return
+5
View File
@@ -35,6 +35,7 @@ TARGET="all"
NOTARY_PROFILE="notary-profile"
NOTARIZE=true
IOS_RELEASE=false
IOS_IPA=false
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
@@ -48,6 +49,7 @@ while [ $# -gt 0 ]; do
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
--no-notarize) NOTARIZE=false ;;
--release) IOS_RELEASE=true ;;
--ipa) IOS_IPA=true ;;
*) fail "unknown argument: $1" ;;
esac
shift
@@ -298,6 +300,9 @@ build_android() {
if [ "$VERSION_EXPLICIT" = true ]; then
args+=(--version "$VERSION")
fi
if [ "$IOS_IPA" = true ]; then
args+=(--ipa)
fi
"$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"}
}
+29 -3
View File
@@ -198,6 +198,10 @@ pack_game_love() {
rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save
# row opens it in-process, so it must be inside the archive (see build.sh).
# Deliberately NO fused mods: a mod inside game.love sits in the read-only
# APK, so the mod manager's Delete can't remove it and it reappears every
# launch. Pokewalker ships as an importable .zip instead, which gives it
# a real install/upgrade/delete lifecycle.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
@@ -290,10 +294,32 @@ require_android_sdk() {
# --------------------------------------------------------------- gradle
run_gradle() {
local task="assembleEmbedNoRecordDebug"
say "building APK ($task)"
local build_dir="$ANDROID_DIR"
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
# the project path ("Your APP_BUILD_SCRIPT points to an unknown file").
# When this checkout lives at a spaced path (e.g. "~/xCode Projects/..."),
# shadow the android tree to a space-free location and build there; the
# shadow persists across runs so gradle/ndk builds stay incremental.
case "$ANDROID_DIR" in
*" "*)
build_dir="${TMPDIR:-/tmp}/gen1recomp-android-shadow"
say "path contains spaces (ndk-build cannot handle them);"
say "shadow-building in: $build_dir"
mkdir -p "$build_dir"
rsync -a --delete \
--exclude=".gradle" --exclude="app/build" --exclude="love/build" \
--exclude="local.properties" \
"$ANDROID_DIR/" "$build_dir/"
if [ -f "$ANDROID_DIR/local.properties" ]; then
cp "$ANDROID_DIR/local.properties" "$build_dir/local.properties"
fi
;;
esac
say "building APK ($task)"
if ! (
cd "$ANDROID_DIR"
cd "$build_dir"
./gradlew --no-daemon "$task"
); then
fail "gradle $task failed.
@@ -302,7 +328,7 @@ run_gradle() {
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
fi
local out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug"
if [ -d "$out_dir" ]; then
say "APK output:"
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
+199 -56
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 11.5's
# official iOS Xcode project (love-11.5-ios-source.zip).
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 12.0's
# iOS Xcode project.
#
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--install]
# [--version X.Y.Z] [--package-only]
@@ -12,13 +12,12 @@
# first connected iPhone/iPad (unlock it first)
# --release Release configuration
# --version X.Y.Z stamp MARKETING_VERSION / CURRENT_PROJECT_VERSION
# --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/
# --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
#
# Prerequisites:
# - macOS + Xcode (xcodebuild)
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
#
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
# dist/ios/gen1recomp.ipa (device builds only)
@@ -50,21 +49,26 @@ DISPLAY_NAME="gen1recomp"
# capabilities like HealthKit are involved), so a per-team default
# lets anyone build without colliding with someone else's app
# 4. simulator: the project default (no App ID registration involved)
BUNDLE_ID="${GEN1_BUNDLE_ID:-}"
BUNDLE_ID="${GEN1_BUNDLE_ID:-com.theboisclub.gen1recomp}"
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
fi
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)"
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
APPLE_LIBS_ZIP="love-${LOVE_VERSION}-apple-libraries.zip"
IOS_SOURCE_URL="https://github.com/love2d/love/releases/download/${LOVE_VERSION}/${IOS_SOURCE_ZIP}"
APPLE_LIBS_URL="https://github.com/love2d/love/releases/download/${LOVE_VERSION}/${APPLE_LIBS_ZIP}"
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 12.0)"
LOVE_SOURCE_REF="${LOVE_SOURCE_REF:-main}"
APPLE_DEPENDENCIES_REF="${APPLE_DEPENDENCIES_REF:-main}"
LOVE_SOURCE_REPO="https://github.com/love2d/love.git"
APPLE_DEPENDENCIES_REPO="https://github.com/love2d/love-apple-dependencies.git"
FETCH=false
DEVICE=false
RELEASE=false
PACKAGE_ONLY=false
INSTALL=false
CREATE_IPA=false
# Last resort for an incomplete source export, mirroring build_android.sh.
MANIFEST_BASE_URL="${MANIFEST_BASE_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main}"
MANIFESTS=""
VERSION=""
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
@@ -78,6 +82,7 @@ while [ $# -gt 0 ]; do
--release) RELEASE=true ;;
--package-only) PACKAGE_ONLY=true ;;
--install) INSTALL=true ;;
--ipa) CREATE_IPA=true ;;
--version) VERSION="$2"; shift ;;
-h|--help)
sed -n '2,24p' "$0"
@@ -88,6 +93,10 @@ while [ $# -gt 0 ]; do
shift
done
if $CREATE_IPA; then
DEVICE=true
fi
VERSION_CODE=""
if [ -n "$VERSION" ]; then
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
@@ -150,25 +159,19 @@ fi
# --------------------------------------------------------------- fetch love-src
fetch_love_ios() {
mkdir -p "$CACHE"
local zip_path="$CACHE/$IOS_SOURCE_ZIP"
if [ ! -f "$zip_path" ]; then
say "downloading $IOS_SOURCE_ZIP (LÖVE $LOVE_VERSION iOS sources)"
curl -fL --progress-bar "$IOS_SOURCE_URL" -o "$zip_path" \
|| fail "download failed: $IOS_SOURCE_URL"
else
say "using cached $zip_path"
fi
say "extracting into $LOVE_SRC"
rm -rf "$LOVE_SRC"
local tmp
tmp="$(mktemp -d "$CACHE/extract.XXXXXX")"
unzip -q "$zip_path" -d "$tmp"
# Zip root is love-<version>-ios-source/
local extracted
extracted="$(find "$tmp" -maxdepth 1 -mindepth 1 -type d ! -name '__MACOSX' | head -1)"
[ -n "$extracted" ] || fail "unexpected layout inside $IOS_SOURCE_ZIP"
mv "$extracted" "$LOVE_SRC"
say "fetching LÖVE $LOVE_VERSION sources ($LOVE_SOURCE_REF)"
git clone --depth 1 --branch "$LOVE_SOURCE_REF" "$LOVE_SOURCE_REPO" "$tmp/love" \
|| fail "failed to fetch LÖVE sources from $LOVE_SOURCE_REPO"
say "fetching Apple dependencies ($APPLE_DEPENDENCIES_REF)"
git clone --depth 1 --branch "$APPLE_DEPENDENCIES_REF" "$APPLE_DEPENDENCIES_REPO" "$tmp/dependencies" \
|| fail "failed to fetch Apple dependencies from $APPLE_DEPENDENCIES_REPO"
rm -rf "$LOVE_SRC"
mv "$tmp/love" "$LOVE_SRC"
mkdir -p "$LIBS_DIR" "$XCODE_DIR/shared"
cp -R "$tmp/dependencies/iOS/libraries/." "$LIBS_DIR"
cp -R "$tmp/dependencies/shared/." "$XCODE_DIR/shared"
rm -rf "$tmp"
say "love-src ready (LÖVE $LOVE_VERSION)"
}
@@ -178,18 +181,12 @@ if [ ! -d "$XCODE_DIR/love.xcodeproj" ]; then
fetch_love_ios
else
fail "LÖVE $LOVE_VERSION iOS sources not found at mobile/ios/love-src/.
Fetch them (documented download of love-${LOVE_VERSION}-ios-source.zip):
Fetch them:
scripts/build_ios.sh --fetch
Or manually:
mkdir -p mobile/ios/cache
curl -fL -o mobile/ios/cache/$IOS_SOURCE_ZIP \\
$IOS_SOURCE_URL
unzip -q mobile/ios/cache/$IOS_SOURCE_ZIP -d mobile/ios/cache
mv mobile/ios/cache/love-${LOVE_VERSION}-ios-source mobile/ios/love-src
See mobile/ios/README.md."
fi
elif $FETCH; then
say "love-src already present; skipping download (delete mobile/ios/love-src to refresh)"
say "love-src already present; skipping fetch (delete mobile/ios/love-src to refresh)"
fi
[ -d "$XCODE_DIR/love.xcodeproj" ] \
@@ -197,25 +194,16 @@ fi
# --------------------------------------------------------------- apple libraries
require_ios_libraries() {
if [ -d "$LIBS_DIR/SDL2.xcframework" ]; then
if [ -d "$LIBS_DIR/SDL2.xcframework" ] && [ -d "$XCODE_DIR/shared/Frameworks/SDL3.xcframework" ]; then
return 0
fi
fail "prebuilt iOS libraries missing at:
$LIBS_DIR
love-ios expects SDL2.xcframework (and friends) there.
and shared/Frameworks.
The official love-${LOVE_VERSION}-ios-source.zip normally includes them.
If they are absent, install love-${LOVE_VERSION}-apple-libraries.zip:
Re-fetch the LÖVE $LOVE_VERSION source tree and its Apple dependencies:
mkdir -p mobile/ios/cache
curl -fL -o mobile/ios/cache/$APPLE_LIBS_ZIP \\
$APPLE_LIBS_URL
unzip -q mobile/ios/cache/$APPLE_LIBS_ZIP -d mobile/ios/cache
rm -rf mobile/ios/love-src/platform/xcode/ios/libraries
cp -R mobile/ios/cache/love-apple-dependencies/iOS/libraries \\
mobile/ios/love-src/platform/xcode/ios/libraries
See mobile/ios/README.md (Apple libraries dependency)."
scripts/build_ios.sh --fetch"
}
require_ios_libraries
@@ -228,7 +216,93 @@ apply_ios_branding() {
cp "$OVERLAY_PLIST" "$dest"
}
apply_ios_icon() {
local source="$ROOT/assets/logo/logo.png"
local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset"
[ -f "$source" ] || fail "missing iOS icon source: $source"
[ -d "$target" ] || fail "missing iOS app icon set: $target"
local entry name size
while IFS=: read -r name size; do
sips -z "$size" "$size" "$source" --out "$target/$name" >/dev/null
done <<'EOF'
icon-1024pt@1x.png:1024
icon-29pt@1x.png:29
icon-29pt@2x.png:58
icon-29pt@3x.png:87
icon-40pt@1x.png:40
icon-40pt@2x.png:80
icon-40pt@3x.png:120
icon-60pt@2x.png:120
icon-60pt@3x.png:180
icon-76pt@1x.png:76
icon-76pt@2x.png:152
icon-83.5pt@2x.png:167
EOF
}
# --------------------------------------------------------------- game.love
# Every version's import manifest has to ship or that game's ROM import fails in
# the built app: decodeManifest (src/import/RomImporter.lua) errors outright when
# one is absent, and dev reads them off the source tree, so the miss only ever
# shows up in a build. iOS shipped without the Yellow one in 0.1.45 to 0.1.47
# for exactly that reason.
#
# The list is READ OUT OF src/core/GameVersion.lua rather than hand-kept here, so
# a fourth version cannot silently ship without its manifest, and a missing file
# is recovered from Git or the project repo the same way build_android.sh already
# recovers Yellow's. Recovery is a last resort for an incomplete source export:
# a manifest carries extraction metadata only, never a ROM or game data.
manifest_paths() {
python3 - "$ROOT/src/core/GameVersion.lua" <<'PY'
import re, sys
src = open(sys.argv[1]).read()
print(" ".join(dict.fromkeys(re.findall(r'manifest\s*=\s*"([^"]+)"', src))))
PY
}
manifest_is_valid() {
python3 - "$1" <<'PY'
import json, pathlib, sys
try:
m = json.loads(pathlib.Path(sys.argv[1]).read_text())
except (OSError, ValueError):
raise SystemExit(1)
sha = m.get("romSha1")
raise SystemExit(0 if isinstance(sha, str) and len(sha) == 40 else 1)
PY
}
ensure_manifests() {
MANIFESTS="$(manifest_paths)"
[ -n "$MANIFESTS" ] \
|| fail "could not read any manifest path out of src/core/GameVersion.lua"
local rel staged
for rel in $MANIFESTS; do
if manifest_is_valid "$ROOT/$rel"; then continue; fi
warn "$rel is missing or invalid; recovering it before packaging"
staged="$(mktemp)"
if git -C "$ROOT" show "HEAD:$rel" > "$staged" 2>/dev/null \
&& manifest_is_valid "$staged"; then
mkdir -p "$ROOT/$(dirname "$rel")"
mv "$staged" "$ROOT/$rel"
say "restored $rel from this checkout's Git data"
continue
fi
if command -v curl >/dev/null 2>&1 \
&& curl --fail --location --retry 2 --connect-timeout 15 \
--output "$staged" "$MANIFEST_BASE_URL/$rel" \
&& manifest_is_valid "$staged"; then
mkdir -p "$ROOT/$(dirname "$rel")"
mv "$staged" "$ROOT/$rel"
say "downloaded $rel from the project repository"
continue
fi
rm -f "$staged"
fail "$rel is unavailable: Git recovery failed and $MANIFEST_BASE_URL/$rel could not be downloaded"
done
say "import manifests: $MANIFESTS"
}
pack_game_love() {
say "packing game.love for love-ios resources"
mkdir -p "$RESOURCES_DIR"
@@ -240,9 +314,10 @@ pack_game_love() {
# it reappears every launch. Mods install as .zips at runtime instead
# (launcher -> MODS -> Import mod .zip), the same lifecycle as every
# other platform.
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
$MANIFESTS \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
# NOTE: grep -q here would race pipefail — it exits on first match, unzip
@@ -252,8 +327,19 @@ pack_game_love() {
| grep -E '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' >/dev/null; then
fail "game.love unexpectedly contains generated ROM data"
fi
unzip -Z1 "$LOVE_FILE" | grep -x 'tools/save-editor/App.lua' >/dev/null \
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
# Same required-file gate as scripts/build.sh and scripts/build_android.sh.
# iOS only checked App.lua, which is why the Yellow manifest shipped missing
# in 0.1.45 through 0.1.47: decodeManifest (src/import/RomImporter.lua) errors
# outright when a version's manifest is absent, so Import ROM on Yellow died
# in the built app while dev, which reads the source tree, stayed green.
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
for required in src/update/Boot.lua tools/save-editor/App.lua \
tools/save-editor/Kit.lua tools/save-editor/panels/Party.lua \
$MANIFESTS; do
printf '%s\n' "$archive_entries" | grep -qx "$required" \
|| fail "game.love is missing $required"
done
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
}
@@ -348,6 +434,43 @@ 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
# patch still links and still runs, then finds the field nil the moment anyone
# taps Import ROM (#482). #539 made that degrade to the copy-into-Files flow
# rather than crash, which is the right floor, but a build with no picker at all
# is a silent downgrade, so fail here instead of shipping one.
#
# Checked against the built binary rather than the source, because patching
# love-src proves nothing about what Xcode actually compiled: the shipped
# 0.1.45/0.1.46/0.1.47 IPAs all DO carry the bridge, so the reports that blamed
# a missing patch step were self-built IPAs, exactly the case this catches.
verify_native_bridge() {
local app="$1"
local exe bin missing=""
exe="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' \
"$app/Info.plist" 2>/dev/null || true)"
bin="$app/${exe:-love}"
[ -f "$bin" ] || bin="$app/love"
if [ ! -f "$bin" ]; then
warn "no executable inside $(basename "$app"); skipping native bridge check"
return 0
fi
# grep -q here would race pipefail the same way pack_game_love documents:
# it exits on first match, strings dies of SIGPIPE, the pipeline "fails"
# nondeterministically. >/dev/null keeps grep reading the whole stream.
for sym in pickFile createFile; do
strings -a "$bin" | grep -x "$sym" >/dev/null || missing="$missing $sym"
done
if [ -n "$missing" ]; then
fail "built app has no native bridge (missing:$missing).
Import ROM would fall back to copy-into-Files instead of opening the picker.
mobile/ios/patch_love_src.py did not take. Re-run:
scripts/build_ios.sh --fetch && scripts/build_ios.sh"
fi
say "native bridge present (pickFile, createFile)"
}
run_xcodebuild() {
local config sdk destination
if $RELEASE; then
@@ -420,11 +543,27 @@ run_xcodebuild() {
say "xcodebuild love-ios ($config / $sdk)"
set +e
(
cd "$XCODE_DIR"
xcodebuild "${args[@]}"
)
local xc_status=$?
local xc_status
if command -v xcbeautify >/dev/null 2>&1; then
(
cd "$XCODE_DIR"
xcodebuild "${args[@]}"
) 2>&1 | xcbeautify
local pipeline_status=("${PIPESTATUS[@]}")
local xcode_status=${pipeline_status[0]}
local beautify_status=${pipeline_status[1]}
if [ "$xcode_status" -ne 0 ]; then
xc_status=$xcode_status
else
xc_status=$beautify_status
fi
else
(
cd "$XCODE_DIR"
xcodebuild "${args[@]}"
)
xc_status=$?
fi
set -e
if [ "$xc_status" -ne 0 ]; then
fail "xcodebuild failed (exit $xc_status).
@@ -455,6 +594,8 @@ run_xcodebuild() {
cp "$LOVE_FILE" "$app/game.love"
fi
verify_native_bridge "$app"
local dist_dir="$DIST/${config}-${sdk}"
rm -rf "$dist_dir"
mkdir -p "$dist_dir"
@@ -524,8 +665,10 @@ install_to_device() {
# --------------------------------------------------------------- main
apply_ios_branding
apply_ios_icon
say "applying iOS native bridge patches (picker/Files support)"
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
ensure_manifests
pack_game_love
ensure_game_love_in_xcode
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Static analysis for the engine Lua (config: .luacheckrc).
#
# Complements scripts/test.sh: the tests prove behavior, luacheck catches the
# defects that never run in a green test -- undefined globals/locals (the
# class that hid a music.volume crash: a hook read a `state` that was still
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
# cosmetic categories the codebase lives with, so what prints is worth a look.
#
# scripts/lint.sh lint src/
# scripts/lint.sh src tools lint specific paths
#
# Install once with: luarocks install luacheck
set -uo pipefail
cd "$(dirname "$0")/.."
if ! command -v luacheck >/dev/null 2>&1; then
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
exit 2
fi
luacheck "${@:-src}"
+411 -68
View File
@@ -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")
@@ -50,6 +51,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
@@ -291,10 +327,17 @@ function BattleState:picImage(img)
or PaletteFX.mode == "classic"
if self.grayPics or mono then return grayImage(img) end
-- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go
-- dark with the HP bars while the blackout text is up (#292). Below the
-- mono check on purpose: the forced-mono modes re-threshold the whole
-- frame downstream, and the DMG had no SGB darkening to begin with.
if self.blackedOut then return blackImage(self.data, img) end
-- dark with the HP bars while the blackout text is up (#292). The intro
-- silhouette slide (SlidePlayerAndEnemySilhouettesOnScreen) darkens the
-- same way: the original slides both pics in under the %11100100
-- silhouette palette and only runs SET_PAL_BATTLE once they have landed,
-- so a still-sliding pic reads as a black silhouette, exactly like the
-- evolution movie's PAL_BLACK (#577). Below the mono check on purpose:
-- the forced-mono modes re-threshold the whole frame downstream, and the
-- DMG had no SGB darkening to begin with.
if self.blackedOut or (self.introSlide or 0) > 0 then
return blackImage(self.data, img)
end
return fadeImage(img, self:activeBgp())
end
@@ -645,17 +688,63 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
return self
end
-- The disguise itself. InitWildBattle .isGhost (engine/battle/core.asm)
-- swaps only the pic and the nick -- wEnemyMonSpecies2 still holds the real
-- species, so the SGB palette stays the disguised mon's. ghostReal keeps
-- what LoadEnemyMonData puts back when the scope unveils.
local function disguiseAsGhost(self)
self.ghostReal = { name = self.enemy.name, sprite = self.enemy.sprite }
self.enemy.name = "GHOST"
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
monPalette(self.data, self.enemy.mon.species))
self.introText = Strings("The GHOST\nappeared!")
end
-- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope
-- the enemy is "GHOST", you're too scared to attack, and balls fail.
function BattleState:makeGhost()
self.ghost = true
self.enemy.name = "GHOST"
-- the ghost keeps the disguised mon's SGB palette: InitWildBattle
-- swaps only the pic, wEnemyMonSpecies2 still holds the real species
-- (engine/battle/core.asm InitWildBattle .isGhost)
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
monPalette(self.data, self.enemy.mon.species))
self.introText = Strings("The GHOST\nappeared!")
disguiseAsGhost(self)
end
-- The RESTLESS SOUL with the SILPH SCOPE in the bag. Mechanically this is
-- not a ghost battle at all -- IsGhostBattle (core.asm) returns false the
-- moment the scope is in the bag, so the mon attacks, is attacked and rolls
-- for flight normally -- but InitWildBattle still enters disguised: its
-- .isGhost branch fires on wCurOpponent == RESTLESS_SOUL as well. Taking
-- the disguise off is PrintBeginningBattleText .isMarowak's job
-- (engine/battle/common_text.asm), which enter() queues through
-- queueScopeReveal (#492).
function BattleState:makeUnveiledGhost()
self.scopeReveal = true
disguiseAsGhost(self)
end
-- MarowakAnim (engine/battle/ghost_marowak_anim.asm) counted in frames: the
-- ghost pic is copied into OAM and flashed 8 times by xor-ing rOBP1 with
-- $80 (10 frames a side), the BG pic underneath is swapped to the RESTLESS
-- SOUL, rOBP1 is shifted left two bits every 10 frames until the ghost has
-- faded into the paper (3 shifts), and the Marowak copy fades back in the
-- same way (4 shifts). Alpha over the paper is what those OBP shifts look
-- like on screen.
BattleState.GHOST_FLASH_FRAMES = 80
BattleState.GHOST_FADE_OUT_FRAMES = 30
BattleState.GHOST_FADE_IN_FRAMES = 40
BattleState.GHOST_REVEAL_FRAMES = 150
-- The rows .isMarowak prints over the disguise, after the "GHOST appeared!"
-- box enter() has already queued: the unveil line, MarowakAnim, then "Wild
-- MAROWAK appeared!" under the restored name. The fall-through into
-- .playSFX at the end is the same SFX_TRAINER_APPEARED the port does not
-- play for trainer intros either, so it is left out here too.
function BattleState:queueScopeReveal()
local unveiled = self.data.text and self.data.text._UnveiledGhostText
self:say(unveiled
or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!"))
self:act(function() self.ghostReveal = { t = 0 } end)
table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES })
self:say(Strings("Wild %s\nappeared!",
self.ghostReal and self.ghostReal.name or self.enemy.name))
end
-- The old man's catch tutorial (BATTLE_TYPE_OLD_MAN,
@@ -669,6 +758,12 @@ end
function BattleState:makeOldManDemo(name)
self.demo = true
self.demoName = name or "OLD MAN"
-- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType:
-- BATTLE_TYPE_OLD_MAN gets .oldManName + OldManPicBack, BATTLE_TYPE_PIKACHU
-- gets .profOakName + ProfOakPicBack (pokeyellow core.asm). The thrower
-- name the caller passes IS that distinction here, so it picks the pic too
-- -- data/scripts/story2.lua is the only site that names PROF.OAK (#557).
self.oakDemo = name == "PROF.OAK"
-- Yellow's Pallet intro runs this before the player owns any mon
-- (BATTLE_TYPE_PIKACHU precedes the lab gift), so newWild flagged the
-- battle dead for lack of a party. The demo never sends out, draws, or
@@ -785,9 +880,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
@@ -798,14 +914,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
@@ -880,6 +1012,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
@@ -931,6 +1071,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)
@@ -951,6 +1097,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
@@ -1022,17 +1178,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
@@ -1041,6 +1218,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
@@ -1067,8 +1245,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
@@ -1190,7 +1378,11 @@ function BattleState:enter()
Music.playBattle(self.data, self.musicKind)
-- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both
-- sides slide in; the trainer pics stay up until the send-outs
self.introSlide = 40
-- 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 /
@@ -1214,7 +1406,8 @@ function BattleState:enter()
-- fade, the ground-padding measurement and battle_sprite_scales.
local backPath, backTrueColor =
require("src.pokemon.Sprites").playerPath(self.data, "back",
{ kind = "battle", demo = self.demo, battle = self })
{ kind = "battle", demo = self.demo, oakDemo = self.oakDemo,
battle = self })
self.playerBackPic = getImage(backPath,
namedPalette(self.data, "MEWMON"), backTrueColor)
self.showPlayerBack = self.playerBackPic ~= nil
@@ -1229,11 +1422,35 @@ function BattleState:enter()
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry
-- sounds with the "Wild X appeared!" box instead of waiting on the A
-- press that clears its `prompt` (#303). The Silph-Scope-less tower
-- ghost gets no cry at all (common_text.asm:43-48).
if self.kind ~= "trainer" and self.kind ~= "link" and not self.ghost then
-- ghost gets no cry at all (common_text.asm:43-48), and neither does the
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
if self.kind ~= "trainer" and self.kind ~= "link"
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)
if self.scopeReveal then self:queueScopeReveal() end
-- _InitBattleCommon (core.asm:6755-6762): the instant the intro text is
-- dismissed both HUD blocks are cleared and ClearSprites drops the
-- pokeball OAM, so the intro chrome never returns for the rest of the
@@ -1271,6 +1488,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,
@@ -2442,24 +2667,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
@@ -2663,6 +2891,35 @@ function BattleState:updateFx()
end
end
end
-- The Silph Scope unveil (MarowakAnim; queueScopeReveal parks a wait row
-- of GHOST_REVEAL_FRAMES over it): the ghost flashes, fades out, the pic
-- and the nick swap back the way LoadEnemyMonData restores them, and the
-- real mon fades in (#492).
local gr = self.ghostReveal
if gr and self.enemy then
gr.t = gr.t + 1
local pf = self:picFxFor(self.enemy)
local flashEnd = BattleState.GHOST_FLASH_FRAMES
local outEnd = flashEnd + BattleState.GHOST_FADE_OUT_FRAMES
if gr.t <= flashEnd then
pf.fade = (math.floor((gr.t - 1) / 10) % 2 == 1) and 0.5 or 1
elseif gr.t <= outEnd then
pf.fade = 1 - math.ceil((gr.t - flashEnd) / 10) / 3
else
if not gr.swapped then
gr.swapped = true
local real = self.ghostReal
if real then
self.enemy.name = real.name or self.enemy.name
self.enemy.sprite = real.sprite or self.enemy.sprite
end
end
pf.fade = math.min(1, math.ceil((gr.t - outEnd) / 10) / 4)
end
if gr.t >= BattleState.GHOST_REVEAL_FRAMES then
self.ghostReveal, self.scopeReveal, pf.fade = nil, nil, nil
end
end
-- the send-out grow-in (AnimateSendingOutMon): 3+4+5 frames, then
-- the pic draws at full size again
if self.growIn then
@@ -2677,10 +2934,24 @@ function BattleState:updateFx()
-- so a sounding siren rides out the next hit's HP drain instead of
-- dropping out mid-announcement (#293)
self.lowHealthAlarmOn = self:lowHealthAlarmActive()
if self.lowHealthAlarmOn then
Sound.startLoop(self.data, "Low_Health_Alarm")
-- battle.low_health_alarm: on/off toggle for the siren loop, ctx.on
-- mirrors self.lowHealthAlarmOn. Vanilla just starts/stops the loop
-- each frame; a mod can wrap this to reshape the toggle (e.g. force
-- ctx.on false after some budget) before letting vanilla act on it.
if Runtime.wantsHook("battle.low_health_alarm") then
Runtime.call("battle.low_health_alarm", function(ctx)
if ctx.on then
Sound.startLoop(ctx.battle.data, "Low_Health_Alarm")
else
Sound.stopLoop("Low_Health_Alarm")
end
end, { on = self.lowHealthAlarmOn, battle = self })
else
Sound.stopLoop("Low_Health_Alarm")
if self.lowHealthAlarmOn then
Sound.startLoop(self.data, "Low_Health_Alarm")
else
Sound.stopLoop("Low_Health_Alarm")
end
end
end
@@ -3194,10 +3465,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
@@ -3216,7 +3490,10 @@ function BattleState:onFaint(battler)
end
end
function BattleState:enemyMonFainted()
-- Exp for the defeated enemy, shared by the faint path (enemyMonFainted)
-- and, when a mod's battle.catch_exp hook says so, the catch path
-- (storeCaughtMon).
function BattleState:awardExp()
-- exp is split among the mons that fought this enemy
-- (engine/battle/experience.asm); traded mons earn x1.5; each
-- participant gets the full stat exp
@@ -3294,27 +3571,44 @@ function BattleState:enemyMonFainted()
end
end
end
-- with EXP.ALL, participants split half the exp and the other half
-- is divided among the whole party (engine/battle/experience.asm)
local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
for _, mon in ipairs(alive) do
applyShare(mon, participants * (expAll and 2 or 1), true)
end
if expAll then
-- the second GainExperience pass sets the gain flags for the WHOLE
-- party, so DivideExpDataByNumMonsGainingExp divides the already
-- halved-and-participant-divided exp again by the party count, and
-- .partyMonLoop still skips fainted mons (core.asm:818-858 +
-- experience.asm:9-13); each mon gets its own GainedText with the
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
-- summary line
for _, mon in ipairs(self.game.save.party) do
if mon.hp > 0 then
applyShare(mon, math.max(1, participants) * #self.game.save.party * 2, "expAll")
-- battle.exp_award: the participant/EXP.ALL split, factored out so a
-- mod can replace it wholesale (e.g. a flat undivided share to every
-- non-fainted party mon) without re-deriving participants/alive.
-- ctx.applyShare(mon, split, announce) is the same helper vanilla uses.
local function vanillaExpAward(ctx)
-- with EXP.ALL, participants split half the exp and the other half
-- is divided among the whole party (engine/battle/experience.asm)
local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
for _, mon in ipairs(ctx.alive) do
ctx.applyShare(mon, ctx.participants * (expAll and 2 or 1), true)
end
if expAll then
-- the second GainExperience pass sets the gain flags for the WHOLE
-- party, so DivideExpDataByNumMonsGainingExp divides the already
-- halved-and-participant-divided exp again by the party count, and
-- .partyMonLoop still skips fainted mons (core.asm:818-858 +
-- experience.asm:9-13); each mon gets its own GainedText with the
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
-- summary line
for _, mon in ipairs(self.game.save.party) do
if mon.hp > 0 then
ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll")
end
end
end
end
local awardCtx = { battle = self, participants = participants, alive = alive,
applyShare = applyShare }
if Runtime.wantsHook("battle.exp_award") then
Runtime.call("battle.exp_award", vanillaExpAward, awardCtx)
else
vanillaExpAward(awardCtx)
end
self.participants = {}
end
function BattleState:enemyMonFainted()
self:awardExp()
if self.kind == "trainer" then
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
@@ -3353,10 +3647,12 @@ function BattleState:enemyMonFainted()
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
local shiftSwitchMon = nil
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
-- _TrainerAboutToUseText: "X is" / "about to use" then cont nick,
-- then para "Will PLAYER" / "change POKéMON?" with YES/NO.
self:say(Strings("%s is\nabout to use", self.trainer.name))
self:say(Strings("%s!", nextName))
-- _TrainerAboutToUseText (data/text/text_2.asm): "X is" / "about to
-- use" then a CONT to the nick, not a fresh box -- the box scrolls
-- "X is" off so "about to use" stays above the name, instead of the
-- page ending on a bare nick (#565). Then para "Will PLAYER" /
-- "change POKéMON?" with YES/NO.
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
self:sayChoice(
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
function(yes)
@@ -3474,8 +3770,18 @@ function BattleState:enemyMonFainted()
-- PrintEndBattleText prints one text box; a `para` (\f) inside it
-- starts a fresh page, which is a message row of its own here (five
-- EndBattleTexts carry one, e.g. _Route9Youngster1EndBattleText)
-- TrainerEndBattleText (home/trainers.asm) prints _TrainerNameText
-- (wNameBuffer then ": ", data/text/text_1.asm) and only then the
-- saved pointer, so the loss line opens with the trainer class tag on
-- its first line (RIVAL1/2/3 tag with the rival's name:
-- TrainerNamePointers aims those entries at wTrainerName). The tag
-- prints once, so a `para` page carries no second copy (#566).
local tag = self.trainer and self.trainer.name
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
if page ~= "" then self:sayNext(page) end
if page ~= "" then
self:sayNext(tag and (tag .. ": " .. page) or page)
tag = nil
end
end
end
self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize))
@@ -3875,6 +4181,12 @@ function BattleState:storeCaughtMon()
-- (item_effects.asm:472-501), regenerating its move list from the
-- base data -- a Mimic'd slot never leaves the battle with it
self:restoreMimicked(self.enemy)
-- battle.catch_exp: vanilla catches never grant exp; a mod can flip
-- this to true to pay out the same award a faint would have.
if Runtime.wantsHook("battle.catch_exp")
and Runtime.call("battle.catch_exp", function() return false end, { battle = self }) then
self:awardExp()
end
local game = self.game
local dex = game.save.pokedex
local species = self.enemy.mon.species
@@ -4118,7 +4430,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
-- ---------------------------------------------------------------------
@@ -4174,10 +4500,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
@@ -4304,6 +4634,18 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
end
if battler.fainted then return end
-- MarowakAnim's rOBP1 fades (updateFx): alpha over the paper is what those
-- palette shifts look like, and the test has to sit ahead of the plain
-- fast path below (#492)
local fadePf = self.picFx and self.picFx[battler]
if fadePf and fadePf.fade then
local cr, cg, cb, ca = love.graphics.getColor()
love.graphics.setColor(cr, cg, cb, ca * fadePf.fade)
love.graphics.draw(img, x, y, 0, scale, scale)
love.graphics.setColor(cr, cg, cb, ca)
return
end
local pf = self.picFx and self.picFx[battler]
if not pf or (not pf.kind and not pf.hidden and not pf.minimized
and (pf.ox or 0) == 0 and (pf.oy or 0) == 0) then
@@ -5069,7 +5411,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) * 4 -- intro slide-in offset
-- 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
+25
View File
@@ -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 <USER>/<TARGET> 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
+41 -6
View File
@@ -183,6 +183,21 @@ local function drawCommandMenu(battle)
Font.drawBox(0, 13, 20, 5)
Font.drawBox(20, 13, 18, 5)
love.graphics.setColor(0, 0, 0, 1)
-- The old-man / PROF.OAK catch demo has no party, so makeOldManDemo parks
-- the WILD mon in battle.player as a placeholder (BattleState:1209). The
-- classic layout's demo branch never names anyone (DisplayBattleMenu,
-- core.asm:2038-2049, just draws the menu), so naming it here printed
-- "What will PIKACHU do?" over Oak's scripted throw (#557). Leave the
-- prompt side blank and run the same scripted hand the classic does.
if battle.demo then
Font.draw(Strings("FIGHT"), 176, 112)
Font.drawCode(0xE1, 240, 112); Font.drawCode(0xE2, 248, 112)
Font.draw(Strings("ITEM"), 176, 128)
Font.draw(Strings("RUN"), 240, 128)
-- next to FIGHT for the first 80 frames, then ITEM
Font.drawCode(0xED, 168, (battle.demoTimer or 0) <= 80 and 112 or 128)
return
end
Font.draw(Strings("What will"), 8, 112)
local who = battle.player and battle.player.name or ""
Font.draw(fitName(who, 112) .. Strings(" do?"), 8, 128)
@@ -305,22 +320,42 @@ 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) * 4
-- 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
-- battlefield rather than either monster being scaled. wideRegion tells
-- drawBattlerPic its own side window is already the clip.
-- drawBattlerPic its own side window is already the clip. A shake moves
-- each region's clip with its contents, so a pic pushed toward a region
-- edge is not sheared off it (a vertical shake used to clip the player's
-- feet at FIELD_BOTTOM).
battle.wideRegion = true
inRegion(0, 32, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
inRegion(sx, 32 + sy, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
function() battle:drawPicsLayer(slide, 0, 0, "player", true) end)
inRegion(160, 0, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
battle.wideRegion = nil
drawHUDs(battle, slide)
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the
-- shakes move IS the whole screen: PredefShakeScreenHorizontally,
-- PredefShakeScreenVertically and AnimationShakeScreenHorizontallySlow
-- displace the HUDs and the message window along with the pics, exactly as
-- drawClassic offsets its whole BG canvas. Shaking only the two pic
-- regions left the foe gliding sideways across a nailed-down screen on
-- every applying-attack shake -- TAIL WHIP and every other status move
-- (#562). The OAM anim layer stays put, as it does in drawClassic.
local function shaken(fn)
if sx == 0 and sy == 0 then return fn() end
g.push()
g.translate(sx, sy)
fn()
g.pop()
end
shaken(function() drawHUDs(battle, slide) end)
drawAnimationLayer(battle)
drawTextArea(battle)
shaken(function() drawTextArea(battle) end)
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
g.setColor(1, 1, 1, 0.85)
+12 -5
View File
@@ -14,12 +14,12 @@ local MODULES = {
-- Optional for compatibility with developer and stale caches.
local OPTIONAL = { "audio", "palettes", "icons" }
-- The rules the engine still carries as literals. The constants registry
-- deep-merges over these, so a value has to exist before a mod can patch
-- it; each one is the number the engine hard-codes today, so seeding them
-- changes nothing on a mod-free boot.
-- Vanilla defaults for rules exposed through the constants registry. A
-- value has to exist before a mod can patch it; each one matches the
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
-- boot.
local CONSTANT_DEFAULTS = {
bagSize = 20, -- BAG_ITEM_CAPACITY (src/inventory/Bag.lua)
bagSize = 20, -- BAG_ITEM_CAPACITY (Bag.capacity fallback)
partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
moveMax = 4,
@@ -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
+129
View File
@@ -0,0 +1,129 @@
-- 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 on mobile and in headless stubs
-- that lack love.window.
local FaithfulRes = {}
FaithfulRes.WIDTH, FaithfulRes.HEIGHT = 160, 144
FaithfulRes.LEVELS = { 0, 1, 2, 3, 4 }
FaithfulRes.DEFAULT = 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
function FaithfulRes.normalize(v)
v = math.floor(tonumber(v) or FaithfulRes.DEFAULT)
if v < 0 then return 0 end
if v > 4 then return 4 end
return v
end
function FaithfulRes.label(v)
v = FaithfulRes.normalize(v)
if v == 0 then return "OFF" 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()
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.
function FaithfulRes.apply(v)
if FaithfulRes.isMobile() then return false end
if not love or not love.window or not love.window.setMode
or not love.window.getMode then
return false
end
v = FaithfulRes.normalize(v)
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
+17 -4
View File
@@ -10,6 +10,7 @@ local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall
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 +21,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 = 0
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 +38,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
+87
View File
@@ -178,6 +178,22 @@ function Game:step(dt)
-- this logic tick, not one tick later. With no wrapper this is a no-op.
ModRuntime.call("input.step", function() end, self, dt)
self.input:step()
-- A+B+SELECT+START held for 16 steps: SoftReset (home/init.asm) stops the
-- audio, whites the palettes out and falls through into Init, i.e. the
-- power-on boot, so everything unsaved is gone and the title sequence
-- comes back -- exactly what returnToTitle does for the START menu's QUIT.
-- The check lives here, above stack:update, so it fires from any state:
-- overworld, battle, a menu or a cutscene, the way _Joypad's does (#563).
-- Input:reset clears the four still-physically-held buttons so the title
-- screen does not read A as a menu choice on its first frame.
-- a tool mod (or a harness) may hand Game a stand-in input with no
-- chord bookkeeping; those simply never soft reset
if self.input.softResetStep and self.input:softResetStep() then
Input:reset()
TouchControls:reset()
self:returnToTitle()
return
end
-- serviced unconditionally: a link battle's ENet transport must not
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
-- on top of BattleState (see LinkBattle.new)
@@ -249,6 +265,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.
@@ -303,6 +351,14 @@ 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)
Renderer:beginFrame(worldBelow)
for i = self.stack:visibleBase(), #self.stack.states do
local state = self.stack.states[i]
@@ -475,8 +531,16 @@ function Game:restartWithMods()
require("src.core.HostShell").restart()
end
-- Releases reach Input even while a top state captures raw input: a
-- swallowed key-up would strand a held-state flag for a key Input saw go
-- down before the capture armed (the stuck-flag hazard Input:reset
-- exists for). The top state only OBSERVES the release afterwards,
-- unlike onKeyPressed above which owns the press, so BindingsMenu can
-- commit a capture on the key-up (#589).
function Game:keyreleased(key)
Input:keyreleased(key)
local top = self.stack and self.stack:top()
if top and top.onKeyReleased then top:onKeyReleased(key) end
end
function Game:gamepadpressed(joystick, button)
@@ -493,7 +557,10 @@ function Game:gamepadpressed(joystick, button)
end
function Game:gamepadreleased(joystick, button)
-- same observe-after-Input contract as Game:keyreleased (#589)
Input:gamepadreleased(joystick, button)
local top = self.stack and self.stack:top()
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
end
function Game:gamepadaxis(joystick, axis, value)
@@ -631,9 +698,29 @@ 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)
-- Scale the optional presentation extras to the device's performance
-- tier. Every heavy feature was just applied from the stored options
-- above; here we clamp the *live* state down for a weaker device without
-- rewriting what the player saved, so raising the tier later restores
-- their exact TILT / GBC FX / ZOOM / MAX FPS choices. A HIGH tier (the
-- default on a normal desktop, and every options.lua predating this
-- option) clamps nothing, so it is a no-op for the common case.
local caps = require("src.core.Performance").applyOptions(opts)
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end
local Zoom = require("src.render.Zoom")
Zoom.allowSurvey = caps.survey
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
if caps.fpsMax then
local FrameCap = require("src.core.FrameCap")
if FrameCap.current > caps.fpsMax then FrameCap.apply(caps.fpsMax) end
end
Input:applyBindings(opts.bindings)
TouchControls:applyOptions(opts)
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
+21
View File
@@ -26,9 +26,30 @@ end
-- ("Failed to initialize filesystem: already initialized") and the relaunch
-- crashes. So on an AppImage we relaunch the executable; the fresh process's
-- Boot step mounts any downloaded update exactly as a manual relaunch would.
-- Android hits the same wall (#575): the vendored love.cpp loops runlove()
-- in-process on "restart", and PHYSFS_deinit in the old Filesystem module's
-- destructor fails ("files still open") whenever any physfs handle survives
-- lua_close, so the second PHYSFS_init throws the same "already initialized"
-- and the app dies. There we relaunch through the GameActivity.restartApp
-- JNI bridge (love.system.restartApp), which schedules our launch intent
-- and kills the process so no native state can leak into the fresh run.
-- On every other platform the in-process restart works, so keep it.
function HostShell.restart()
if not (love and love.event and love.event.quit) then return end
local osName = love.system and love.system.getOS and love.system.getOS()
if osName == "Android" then
-- restartApp kills the process on success, so a true return is never
-- observed; false means the bridge could not schedule the relaunch.
-- An older APK whose liblove predates the bridge (love.system.restartApp
-- is nil) has no crash-free in-process restart, so quit to the OS
-- cleanly and let the player relaunch by hand -- worse than restarting,
-- but better than the guaranteed crash of quit("restart") (#575).
if love.system.restartApp and love.system.restartApp() then return end
love.event.quit()
return
end
local appimage = os.getenv("APPIMAGE")
if not appimage then
love.event.quit("restart")
+48
View File
@@ -263,8 +263,56 @@ function Input:isDown(btn)
return self.state[btn] or false
end
-- True when the on-screen overlay is one of the live sources holding this
-- button (see overlayPressed above). Player:turnWindow widens the
-- turn-in-place tap window on touch, where a press and release can never be
-- as short as a physical d-pad's (#415).
function Input:isTouchDown(btn)
local sources = self.sources[btn]
return (sources and sources["touch:" .. btn]) and true or false
end
function Input:wasPressed(btn)
return self.pressed[btn] or false
end
-- Soft reset (#563). _Joypad (engine/joypad.asm) tests the RAW joypad read
-- with `cp PAD_BUTTONS` -- an equality, not a mask -- so the combo counts
-- only while A, B, SELECT and START are the only buttons down; any d-pad
-- direction in the mix cancels it. That test sits ahead of the wJoyIgnore
-- and BIT_DISABLE_JOYPAD masking below it, which is why the reset still
-- works mid-battle and mid-cutscene where ordinary input is thrown away.
-- TrySoftReset then burns one DelayFrame per pass and decrements hSoftReset,
-- seeded with 16 by Init (home/init.asm), so the combo has to survive 16
-- consecutive polls. That hold is also what keeps the on-screen overlay
-- safe: it already takes four separate fingers on four separate controls,
-- and they all have to stay put for better than a quarter of a second.
local SOFT_RESET_FRAMES = 16
function Input:softResetHeld()
if not (self.state.a and self.state.b
and self.state.start and self.state.select) then
return false
end
return not (self.state.up or self.state.down
or self.state.left or self.state.right)
end
-- Ticked once per fixed step by Game:step; true on the step the countdown
-- runs out. hSoftReset is never re-seeded on release in the original, so
-- its count leaks across a whole session; a port that copied that would
-- eventually reset on a stray four-button press, so the counter re-arms as
-- soon as the combo drops.
function Input:softResetStep()
if not self:softResetHeld() then
self.softResetFrames = nil
return false
end
local left = (self.softResetFrames or SOFT_RESET_FRAMES) - 1
self.softResetFrames = left
if left > 0 then return false end
self.softResetFrames = nil
return true
end
return Input
+8 -1
View File
@@ -23,6 +23,13 @@ local volumeScale = 1
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
local filterLevel = 0
-- Forward-declared here so applyVolume (below) closes over the real playback
-- state rather than a nil global: the table literal is assigned further down,
-- but a `local state = {}` there would leave every reference above it bound
-- to the global `state`. Before this, registering the `music.volume` mod
-- hook crashed applyVolume on `state.current` (a nil index).
local state
local function applyVolume(src)
if not src then return end
local vol = VOLUME * volumeScale
@@ -63,7 +70,7 @@ local function applyFilter(src)
end
end
local state = {
state = {
current = nil, -- song label
chip = false, -- the playing song is a synthesized channel program
source = nil, -- currently playing source
+156
View File
@@ -0,0 +1,156 @@
-- Graphics performance tier: one knob that scales the port's optional,
-- non-faithful presentation extras down for weaker hardware.
--
-- The heavy extras are all things the Game Boy never had and this port
-- adds on top: the whole-screen 3D TILT (transforms the entire map as a
-- ground plane), the GBC FX post-process shader (a fullscreen pass), and
-- survey ZOOM (zooming out renders the connected neighbor maps -- a lot of
-- extra overdraw). A hard FPS ceiling caps present cost on top of that.
-- None of this touches game logic, which is fixed-step off dt
-- (src/core/FixedStep.lua), so every tier plays identically; they differ
-- only in how much optional eye-candy the renderer is allowed to do.
--
-- The tier is persisted as save.options.performance:
-- auto pick a default from the device (see detect())
-- high everything on -- the historical behavior
-- balanced no TILT, no GBC FX (kept: survey zoom, colors, uncapped FPS)
-- low no TILT, no GBC FX, no survey zoom, FPS capped
--
-- AUTO only chooses the *default* for the current device; every tier is
-- selectable in OPTIONS, so a heuristic that guesses wrong is one row away
-- from being overridden. The clamps are applied live in Game:applyOptions
-- against the stored options without rewriting them, so raising the tier
-- restores exactly the TILT / GBC FX / ZOOM / FPS the player had.
--
-- Zero requires: loads during love.conf and under plain Lua for tools and
-- tests, the same way src/core/GameVersion.lua does.
local Performance = {}
-- Option-row order (auto first, then most to least capable).
Performance.TIERS = { "auto", "high", "balanced", "low" }
Performance.LABELS = {
auto = "AUTO",
high = "HIGH",
balanced = "BALANCED",
low = "LOW",
}
-- What each concrete tier permits. `auto` is resolved to one of these
-- before caps are read, so it has no row here. fpsMax = false means no
-- extra ceiling (the player's own MAX FPS still applies).
Performance.CAPS = {
high = { tilt = true, gbcfx = true, survey = true, fpsMax = false },
balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false },
low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 },
}
-- Live resolved tier (never "auto"); Game:applyOptions sets it and the
-- renderer / options row read it back. Defaults to the unclamped tier so
-- a pre-boot launcher behaves exactly as it always did.
Performance.tier = "high"
local function loveOS()
if love and love.system and love.system.getOS then
return love.system.getOS()
end
return nil
end
local function processorCount()
if love and love.system and love.system.getProcessorCount then
return love.system.getProcessorCount()
end
return nil
end
-- CPU architecture via LuaJIT's jit.arch when present: "arm"/"arm64" on
-- phones and PortMaster handhelds, "x86"/"x64" on desktops. nil under a
-- plain-Lua tool or test with no jit table, which resolves to HIGH (no
-- clamping) so tooling is never surprised.
local function cpuArch()
return (jit and jit.arch) or nil
end
-- Default tier for the current device. Deliberately conservative: only
-- the platforms that are reliably weak drop below HIGH, and AUTO is always
-- overridable, so a wrong guess costs one OPTIONS row. A normal
-- multi-core desktop -- the common case, and every existing install whose
-- options.lua predates this option -- resolves to HIGH and behaves exactly
-- as before.
function Performance.detect()
local os = loveOS()
local arch = cpuArch()
local isArm = arch == "arm" or arch == "arm64"
local cores = processorCount()
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
-- already ships a build for): the weakest target here.
if isArm and os ~= "Android" and os ~= "iOS" then
return "low"
end
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
-- balanced additionally drops the 3D tilt, the heaviest remaining extra.
if os == "Android" or os == "iOS" then
return "balanced"
end
-- Desktop: a dual-core (or single-core) box is the low-end line.
if cores and cores <= 2 then
return "balanced"
end
return "high"
end
-- Fold a stored option value to a concrete tier, resolving "auto" (and any
-- hand-edited garbage) through detect().
function Performance.resolve(value)
if value == "high" or value == "balanced" or value == "low" then
return value
end
return Performance.detect()
end
-- Normalize a stored value to a valid option id (keeps "auto"); a bad value
-- degrades to auto so a corrupt options.lua still lands on something sane.
function Performance.normalize(value)
for _, id in ipairs(Performance.TIERS) do
if value == id then return id end
end
return "auto"
end
-- Row label for a stored value: the tier's own name (AUTO / HIGH / ...).
function Performance.label(value)
return Performance.LABELS[Performance.normalize(value)]
end
-- Concrete caps for a stored option value (resolving auto).
function Performance.caps(value)
return Performance.CAPS[Performance.resolve(value)]
end
-- Resolve the tier for these options, record it live, and return its caps.
-- Called from Game:applyOptions, which clamps the live presentation modules
-- against the returned caps.
function Performance.applyOptions(opts)
local tier = Performance.resolve(opts and opts.performance)
Performance.tier = tier
return Performance.CAPS[tier]
end
-- Cycle the OPTIONS row: auto -> high -> balanced -> low -> auto (dir -1
-- reverses). Lua's `%` is non-negative for a positive modulus, so a
-- negative dir wraps correctly.
function Performance.cycle(value, dir)
value = Performance.normalize(value)
local i = 1
for idx, id in ipairs(Performance.TIERS) do
if id == value then i = idx break end
end
local n = #Performance.TIERS
local nextIdx = (i - 1 + (dir or 1)) % n + 1
return Performance.TIERS[nextIdx]
end
return Performance
+40
View File
@@ -0,0 +1,40 @@
-- Usable window rect for mobile chrome (notch / Dynamic Island / home
-- indicator / Android display cutouts). Wraps love.window.getSafeArea when
-- the engine provides it; otherwise the full graphics window.
--
-- Desktop and headless stubs return the full window, so callers can always
-- layout against this rect without platform branches. Interactive chrome
-- (touch overlay, launcher) should prefer this over getDimensions; the game
-- canvas may still letterbox into the full framebuffer for immersion.
local SafeArea = {}
function SafeArea.rect()
local ww, wh = 0, 0
if love and love.graphics and love.graphics.getDimensions then
ww, wh = love.graphics.getDimensions()
end
if ww <= 0 then ww = 1 end
if wh <= 0 then wh = 1 end
if not (love and love.window and love.window.getSafeArea) then
return 0, 0, ww, wh
end
local x, y, w, h = love.window.getSafeArea()
if type(x) ~= "number" or type(y) ~= "number"
or type(w) ~= "number" or type(h) ~= "number"
or w <= 0 or h <= 0 then
return 0, 0, ww, wh
end
-- Clamp to the drawable window so a bad / mid-rotation backend cannot
-- push layout outside the surface.
x = math.max(0, math.min(x, ww))
y = math.max(0, math.min(y, wh))
w = math.max(1, math.min(w, ww - x))
h = math.max(1, math.min(h, wh - y))
return x, y, w, h
end
return SafeArea
+18 -1
View File
@@ -219,6 +219,15 @@ 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",
ruleset = "gen1_faithful",
-- 0-7 like the GB's NR50 master volume
musicVol = 7,
@@ -239,8 +248,16 @@ 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"
-- picks a default from the device (ARM handhelds/phones drop the heavy
-- extras); scales TILT / GBC FX / survey ZOOM / FPS but never game
-- logic. See src/core/Performance.lua.
performance = "auto",
-- Per-pipeline display levels, keyed by render_pipelines id (see
-- src/render/Pipelines.lua). A level for a mod that is not installed
-- is kept rather than pruned, so re-enabling the mod restores the mode
@@ -1053,7 +1070,7 @@ local function reclaim(save, data, report)
if type(entry) == "table" and known(data.items, entry.id) then
table.remove(orphaned.items, i)
if entry.from == "pcItems" or type(save.inventory) ~= "table"
or not Bag.add(save, entry.id, entry.count or 1) then
or not Bag.add(save, entry.id, entry.count or 1, data) then
save.pcItems = save.pcItems or {}
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
end
+197
View File
@@ -0,0 +1,197 @@
-- 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.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
+42 -26
View File
@@ -23,6 +23,7 @@
-- and a player rebind can never detach the overlay.
local Input = require("src.core.Input")
local SafeArea = require("src.core.SafeArea")
local TouchControls = {}
@@ -92,20 +93,23 @@ function TouchControls.normalizeConfig(tc)
return out
end
-- Pure default layout in LOVE units for a given window size. Shared by
-- layout() and the editor's Reset path so defaults stay in one place.
function TouchControls.defaultLayout(ww, wh)
-- 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)
ox, oy = ox or 0, oy or 0
local short = math.min(ww, wh)
local dpadW = math.min(180, short * 0.34)
local abW = dpadW * 0.46
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
return {
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW },
start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
select = { cx = ox + ww / 2 - ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
}
end
@@ -155,6 +159,7 @@ function TouchControls:applyOptions(opts)
self.enabled = cfg.enabled
self.positions = cfg.positions
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
if not self.enabled then
self.controllerHidden = false
self:reset()
@@ -185,30 +190,37 @@ function TouchControls:visible()
and not self.controllerHidden
end
local function clampZone(zone, ww, wh)
-- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
local function clampZone(zone, x0, y0, x1, y1)
local half = zone.w * 0.5
zone.cx = math.max(half, math.min(ww - half, zone.cx))
zone.cy = math.max(half, math.min(wh - half, zone.cy))
zone.cx = math.max(x0 + half, math.min(x1 - half, zone.cx))
zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
end
-- Layout in LOVE units (density-independent on mobile), recomputed when
-- the window size changes (rotation, resize). Default: d-pad bottom-left,
-- B/A bottom-right with A above B (the Game Boy diagonal), START/SELECT
-- flanking the bottom center. Custom positions (normalized 0..1) override
-- centers while sizes stay derived from the short edge.
-- 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.
function TouchControls:layout()
local ww, wh = love.graphics.getDimensions()
if self.layoutW == ww and self.layoutH == wh and self.L then return self.L end
self.layoutW, self.layoutH = ww, wh
self.L = TouchControls.defaultLayout(ww, wh)
local ox, oy, sw, sh = SafeArea.rect()
if self.layoutW == sw and self.layoutH == sh
and self.layoutOx == ox and self.layoutOy == oy and self.L then
return self.L
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
for _, name in ipairs(CONTROLS) do
local p = self.positions[name]
local zone = self.L[name]
if p and zone then
zone.cx = p.x * ww
zone.cy = p.y * wh
clampZone(zone, ww, wh)
zone.cx = ox + p.x * sw
zone.cy = oy + p.y * sh
clampZone(zone, ox, oy, ox + sw, oy + sh)
end
end
end
@@ -222,21 +234,25 @@ function TouchControls:layout()
end
-- Move one control to a screen-space point and persist its normalized
-- position. Used by the layout editor while dragging.
-- position within the safe rect. Used by the layout editor while dragging.
function TouchControls:setControlCenter(name, cx, cy)
local ww, wh = love.graphics.getDimensions()
local ox, oy, sw, sh = SafeArea.rect()
local L = self:layout()
local zone = L[name]
if not zone then return end
zone.cx, zone.cy = cx, cy
clampZone(zone, ww, wh)
clampZone(zone, ox, oy, ox + sw, oy + sh)
self.positions = self.positions or {}
self.positions[name] = { x = zone.cx / ww, y = zone.cy / wh }
self.positions[name] = {
x = sw > 0 and (zone.cx - ox) / sw or 0,
y = sh > 0 and (zone.cy - oy) / sh or 0,
}
end
function TouchControls:clearPositions()
self.positions = nil
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
end
local function inCircle(zone, x, y, slop)
+1 -1
View File
@@ -214,7 +214,7 @@ function VERBS.give(self, rest)
end
elseif game.data.items and game.data.items[id] then
local n = tonumber(count) or 1
if require("src.inventory.Bag").add(save, id, n) then
if require("src.inventory.Bag").add(save, id, n, game.data) then
self:print(("%s x%d added"):format(id, n))
else
self:print("bag full")
+36
View File
@@ -1179,6 +1179,13 @@ function RomExtractor:extractPokemon()
completed = completed + 1
self:tick("Pokemon", completed, #self.manifest.dexOrder + 10)
end
-- Yellow-only third back pic: LoadPlayerBackPic (engine/battle/core.asm)
-- picks OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for
-- BATTLE_TYPE_PIKACHU, the Pallet Town catch scene (#557). Outside the
-- ticked loop so the progress denominator stays as it was.
if self.symbols["ProfOakPicBack"] then
self:writeCompressedPic("ProfOakPicBack", "battle/profoakb.png")
end
local balls = self:symbol("PokeballTileGraphics")
self:write2bpp(self.rom:bytes(balls.bank, balls.address, 64),
32, 8, "battle/balls.png", true)
@@ -1826,6 +1833,35 @@ function RomExtractor:extractField()
end
end
-- Yellow-only: TalkToPikachu's framed portrait, one 5x5 base frame per
-- PikaPicAnimScript -- each script's FIRST pikapic_loadgfx in
-- data/pikachu/pikachu_pic_animation.asm, the pic PikaPicAnimBGFrames_4
-- (PikaAnimTilemap_1, column order) paints. Scripts 18/22/23/24 have no
-- compressed base and take PikaPicAnimBGFrames_5 -> PikaAnimTilemap_9,
-- which is ROW order over a raw 25-tile sheet, hence no columns flag.
-- Script 26 shares script 11's base. The pikaframe overlays each script
-- draws ON TOP of the base are a second full pose out of the same blob and
-- stay unripped: PikachuFollower.picLift stands in for their motion
-- (#561, still on #407's stand-in).
local PIKAPIC_BASE = {
"Pic_e4000", "Pic_e411c", "Pic_e4272", "Pic_e4383", "Pic_e458b",
"Pic_e467b", "Pic_e476e", "Pic_e49d1", "Pic_e4b39", "Pic_e4c3e",
"Pic_e5000", "Pic_e523f", "Pic_e548e", "Pic_e56d1", "Pic_e5924",
"Pic_e5b7d", "Pic_e5ddd", "GFX_e6020", "Pic_e6340", "Pic_e6587",
"Pic_e67d6", "GFX_e6e6f", "GFX_e718f", "GFX_e74af", "Pic_e77cf",
"Pic_e5000", "Pic_f0abf", "Pic_f0cf4",
}
if self.symbols[PIKAPIC_BASE[1]] then
for script, label in ipairs(PIKAPIC_BASE) do
local path = "pikachu/pikapic_" .. script .. ".png"
if label:sub(1, 4) == "GFX_" then
self:raw2bpp(label, 40, 40, path, { matte = true })
else
self:writeCompressedPic(label, path)
end
end
end
self:raw1bpp("LedgeHoppingShadow", 8, 8,
"fx/shadow.png", true); tick()
for _, spec in ipairs({
+252 -65
View File
@@ -1,10 +1,30 @@
local GameVersion = require("src.core.GameVersion")
local Strings = require("src.core.Strings")
local HostShell = require("src.core.HostShell")
local SafeArea = require("src.core.SafeArea")
local RomImporter = {}
RomImporter.__index = RomImporter
-- love.system.pickFile is a NATIVE BRIDGE, not part of LÖVE: it exists only on
-- builds that compiled one (Android, and iOS builds patched by
-- mobile/ios/patch_love_src.py). A build without it must fall back to the
-- copy-it-into-the-save-folder flow that every caller below already has --
-- calling the nil field instead took the whole app down the moment the player
-- pressed Import ROM:
--
-- src/import/RomImporter.lua: attempt to call field 'pickFile' (a nil value)
--
-- love.system.createFile was already guarded this way at its one call site;
-- these three were not. Every caller here treats `false` as "no picker
-- available" and shows its own notice, so a missing bridge now degrades to
-- exactly the path a picker-less Android device has always taken.
local function pickFile(...)
local fn = love.system.pickFile
if not fn then return false end
return fn(...) and true or false
end
-- Cache generation tag; bump to force every imported version to re-extract.
-- v9: Yellow audio re-anchored on pokeyellow.sym (#522) -- stale caches
-- carry Red's bank $1f header, wave-table, and CryData offsets.
@@ -41,11 +61,22 @@ local REQUIRED_FILES = {
-- them re-imports on its own, without dragging the other versions through a
-- CACHE_FORMAT bump.
local VERSION_REQUIRED_FILES = {
yellow = { "assets/generated/battle/trainers/jessie_james.png" }, -- #439
yellow = {
"assets/generated/battle/trainers/jessie_james.png", -- #439
-- Oak's own back pic and the pikapic base frames only exist in caches
-- built after their manifest symbols landed, so an older Yellow cache
-- has to re-import to stop falling back to the old man's back pic and
-- to the battle front pic (#557, #561). Both are gated on manifest
-- symbols in RomExtractor, so these markers must only ever list files
-- tools/rom_manifest_yellow.json can actually produce -- otherwise the
-- cache reads as incomplete and re-importing cannot clear it.
"assets/generated/battle/profoakb.png",
"assets/generated/pikachu/pikapic_1.png",
},
}
-- "Split-screen ROM selector" first-run palette (matches FirstRun.dc.html from
-- the Claude Design project): a dark neon arcade panel, one column per game.
-- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup):
-- a dark neon arcade panel, one column per game.
-- Red, Blue, and Yellow share the same importer flow once listed in
-- GameVersion.VERSIONS. Values are 0-255 RGB; alpha is applied per draw.
local PAL = {
@@ -555,10 +586,16 @@ function RomImporter.new(onComplete, opts)
onEditTouchControls = opts.onEditTouchControls,
android = android,
ios = mobileOS == "iOS",
-- One startup poll pass: files dropped through the Files app are swept
-- into the save dir before Lua boots (GRBootstrap), but no love.focus
-- event necessarily follows, so consume them via the first poll tick.
pickPending = mobileOS == "iOS" or nil,
-- One startup poll pass on both mobiles. iOS: files dropped through the
-- Files app are swept into the save dir before Lua boots (GRBootstrap) with
-- no love.focus event necessarily following. Android: the SAF picker is a
-- separate activity, and Android is free to destroy GameActivity while it
-- is up (memory pressure, or "Don't keep activities"), so the app RESTARTS
-- instead of resuming and the love.focus(true) that would have consumed the
-- pick never arrives. The file is sitting in the save dir either way, so
-- boot armed and let the first poll tick consume it, rather than making the
-- player tap Import a second time to trigger the scan by hand (#553).
pickPending = android or nil,
-- Android drag: the launcher is handed no move events at all (main.lua
-- forwards neither touchmoved nor mousemoved while it is up), and its mouse
-- emulation is what "no reliable pointer polling" below refers to.
@@ -611,6 +648,8 @@ function RomImporter.new(onComplete, opts)
-- Android SAF create-document: which game's SAVE FILES card should show
-- "Save exported." when export_done.flag appears on focus.
androidPendingExportVersion = nil,
iosPendingKind = nil,
iosPendingVersion = nil,
-- Virtual pointer for handhelds / gamepads (Anbernic stock OS has no
-- mouse). D-pad + left stick move it; A clicks; shoulders cycle tabs;
-- right stick scrolls the save-slot / mods lists.
@@ -792,7 +831,11 @@ local function resetPointerCursor(self)
if not (love.mouse.isCursorSupported and love.mouse.isCursorSupported()) then
return
end
self.arrowCursor = self.arrowCursor or love.mouse.getSystemCursor("arrow")
if not self.arrowCursor then
local ok, cursor = pcall(love.mouse.getSystemCursor, "arrow")
if not ok then return end
self.arrowCursor = cursor
end
love.mouse.setCursor(self.arrowCursor)
end
@@ -978,6 +1021,14 @@ end
-- which focus/Choose consumes on return.
function RomImporter:chooseMod()
if self.workState == "working" then return end
if self.ios and love.system.getPickedFile then
self.iosPendingKind = "mod"
if not pickFile("mod") then
self.iosPendingKind = nil
self.modNotice = { ok = false, text = "Could not open the file picker." }
end
return
end
if self.android then
local name = findPendingMod(true, self.pickSkip)
if name then
@@ -986,7 +1037,7 @@ function RomImporter:chooseMod()
self.modNotice and self.modNotice.ok)
return
end
if not love.system.pickFile("mod") then
if not pickFile("mod") then
self.modNotice = { ok = false,
text = "Could not open the file picker. Copy a mod .zip via USB." }
else
@@ -1039,6 +1090,16 @@ end
-- Android mirrors ROM / mod import via love.system.pickFile("sav").
function RomImporter:chooseSaveImport(version)
if self.workState == "working" then return end
if self.ios and love.system.getPickedFile then
self.iosPendingKind = "sav"
self.iosPendingVersion = version
if not pickFile("sav") then
self.iosPendingKind = nil
self.iosPendingVersion = nil
self.saveNotice[version] = { ok = false, text = "Could not open the file picker." }
end
return
end
if self.android then
local name = findPendingSav(true, self.pickSkip)
if name then
@@ -1049,7 +1110,7 @@ function RomImporter:chooseSaveImport(version)
return
end
self.androidPendingVersion = version
if not love.system.pickFile("sav") then
if not pickFile("sav") then
self.androidPendingVersion = nil
self.saveNotice[version] = { ok = false,
text = "Could not open the file picker. Copy a .sav via USB." }
@@ -1091,7 +1152,7 @@ function RomImporter:exportSave(version)
return
end
self.androidPendingExportVersion = version
if love.system.createFile and love.system.createFile(suggested) then
if love.system.createFile and love.system.createFile(suggested, love.filesystem.getSaveDirectory()) then
self.pickPending = true
self.pickTimer = 0
self.saveNotice[version] = { ok = true,
@@ -1128,6 +1189,14 @@ end
function RomImporter:choose(version)
if self.workState == "working" then return end
self.chooseVersion = version or "red"
if self.ios and love.system.getPickedFile then
self.iosPendingKind = "rom"
if not pickFile("rom") then
self.iosPendingKind = nil
self:setError("Could not open the file picker.")
end
return
end
if self.android then
-- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or
-- a fresh SAF pick). Never reuse an already-imported cart's file -- that
@@ -1137,7 +1206,7 @@ function RomImporter:choose(version)
self:startData(data, name)
elseif consumePickedRomError(self) then
return -- a rejected pick explains itself instead of silently reopening
elseif not love.system.pickFile() then
elseif not pickFile() then
-- Picker unavailable (API < 19, or no document-picker app installed):
-- fall back to the USB folder-drop path as a friendly notice, not an
-- error (which would read as a rejected file).
@@ -1183,13 +1252,30 @@ function RomImporter:choose(version)
end
end
-- iOS: the document picker is an in-process modal sheet, so unlike Android's
-- separate SAF activity there is no love.focus(true) when it dismisses.
-- While a pick is outstanding, poll the save dir for the bridge's delivered
-- file (picked_rom.gb / picked_mod.zip / picked_save.sav / export_done.flag)
-- and run the same refocus import path Android uses.
-- Poll the save dir for a delivered pick (picked_rom.gb / picked_mod.zip /
-- picked_save.sav / export_done.flag) and run the same import path a refocus
-- runs. Both mobiles need this, for different reasons:
--
-- iOS the document picker is an in-process modal sheet, so there is no
-- love.focus(true) when it dismisses -- nothing else would consume it.
-- Android the SAF picker IS a separate activity and normally does refocus,
-- but Android may destroy GameActivity while it is up, in which case
-- the app restarts and that focus event never comes. Polling makes
-- the outcome the same either way instead of leaving the pick on disk
-- for the next tap to find, which is what made users import twice and
-- what made it look random: it depends on memory pressure (#553).
--
-- Deliberately NO timeout. A version of this disarmed the poll after 120s so a
-- cancelled picker would stop scanning, which was wrong on iOS: the picker there
-- is an in-process modal sheet, so update() keeps running while it is open and
-- the window burned down while the player was still browsing Files. The pick
-- then landed with nothing armed to consume it, and because every path here is
-- silent on success the import just did not happen, with no error shown. A
-- half-second directory listing on a menu screen is far cheaper than an import
-- that vanishes, so the poll stays armed until something is actually consumed.
function RomImporter:_pollPickedFiles(dt)
if not (self.ios and self.pickPending) then return end
if not self.pickPending then return end
if self.workState == "working" then return end
self.pickTimer = (self.pickTimer or 0) + dt
if self.pickTimer < 0.5 then return end
@@ -1225,6 +1311,37 @@ end
function RomImporter:update(dt)
self.pulse = self.pulse + dt
self:_updatePadCursor(dt)
if self.ios and love.system.getPickedFile and self.workState ~= "working" then
local path = love.system.getPickedFile()
if path then
local kind = self.iosPendingKind or "rom"
local version = self.iosPendingVersion
self.iosPendingKind = nil
self.iosPendingVersion = nil
if kind == "mod" then
self:_installMod(path)
elseif kind == "sav" then
self:_importSave(version or self:_savedropTarget(), path)
else
self:startPath(path)
end
elseif love.system.getPickError then
local errorText = love.system.getPickError()
if errorText then
local kind = self.iosPendingKind or "rom"
local version = self.iosPendingVersion or self:_savedropTarget()
self.iosPendingKind = nil
self.iosPendingVersion = nil
if kind == "mod" then
self.modNotice = { ok = false, text = errorText }
elseif kind == "sav" then
self.saveNotice[version] = { ok = false, text = errorText }
else
self:setError(errorText)
end
end
end
end
self:_pollPickedFiles(dt)
if self.workState ~= "working" or not self.worker then return end
local started = love.timer.getTime()
@@ -1249,10 +1366,10 @@ local PAD_DPAD_SPEED = 420
function RomImporter:_activatePadCursor()
if self._padCursorActive then return end
local w, h = love.graphics.getDimensions()
local ox, oy, w, h = SafeArea.rect()
if not self._padInited then
self._padCursor.x = w * 0.5
self._padCursor.y = h * 0.45
self._padCursor.x = ox + w * 0.5
self._padCursor.y = oy + h * 0.45
self._padInited = true
end
self._padCursorActive = true
@@ -1269,6 +1386,7 @@ function RomImporter:_cycleTab(delta)
self._slotPress = nil
self._modPress = nil
self._findSearchFocus = false
self:_disarmTextInput()
end
function RomImporter:_updatePadCursor(dt)
@@ -1298,11 +1416,11 @@ function RomImporter:_updatePadCursor(dt)
if mag > 1 then dx, dy = dx / mag, dy / mag end
local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD)
and PAD_SPEED or PAD_DPAD_SPEED
local w, h = love.graphics.getDimensions()
local ox, oy, w, h = SafeArea.rect()
local nx = self._padCursor.x + dx * speed * dt
local ny = self._padCursor.y + dy * speed * dt
self._padCursor.x = math.max(0, math.min(w, nx))
self._padCursor.y = math.max(0, math.min(h, ny))
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
end
-- Right stick scrolls the active list (save slots or mods), or the whole page
@@ -1680,7 +1798,10 @@ function RomImporter:_resetFrameRects()
end
function RomImporter:draw()
local width, height = love.graphics.getDimensions()
-- Full window for immersive backdrop; safe rect for interactive chrome so
-- notch / Dynamic Island / home indicator / Android cutouts are respected.
local fullW, fullH = love.graphics.getDimensions()
local ox, oy, width, height = SafeArea.rect()
local s = clamp(height / 768, 0.7, 1.6)
local pulse = self.pulse
self._s = s
@@ -1699,8 +1820,9 @@ function RomImporter:draw()
self._anyHover = false
self:_resetFrameRects()
-- Fonts + size-dependent scenery, rebuilt only when the window size changes.
local fontKey = ("%dx%d"):format(width, height)
-- Fonts + size-dependent scenery, rebuilt only when the window / safe
-- area changes (rotation, resize, inset changes).
local fontKey = ("%dx%d@%d,%d"):format(fullW, fullH, ox, oy)
if self.fontKey ~= fontKey then
self.fontKey = fontKey
local function f(px) return love.graphics.newFont(math.max(8, math.floor(px + 0.5))) end
@@ -1724,10 +1846,10 @@ function RomImporter:draw()
-- Background: a radial gradient (bright navy at top-centre -> near black).
-- A triangle fan from the top-centre gives the radial falloff; the screen
-- is cleared to the outer colour first so the corners it does not reach
-- match seamlessly.
-- match seamlessly. Sized to the full window so unsafe edges stay filled.
do
local cx, cy = width / 2, 0
local rx, ry = width * 1.3, height * 1.08
local cx, cy = fullW / 2, 0
local rx, ry = fullW * 1.3, fullH * 1.08
local n = 72
local verts = { { cx, cy, 0, 0,
PAL.bgTop[1] / 255, PAL.bgTop[2] / 255, PAL.bgTop[3] / 255, 1 } }
@@ -1741,8 +1863,8 @@ function RomImporter:draw()
-- CRT vignette: a gentle edge darkening, centred slightly above the middle.
do
local cx, cy = width / 2, height * 0.45
local rx, ry = width * 0.78, height * 0.78
local cx, cy = fullW / 2, fullH * 0.45
local rx, ry = fullW * 0.78, fullH * 0.78
local n = 72
local verts = { { cx, cy, 0, 0, 0, 0, 0, 0 } }
for i = 0, n do
@@ -1764,7 +1886,7 @@ function RomImporter:draw()
self.scanlineImage:setWrap("repeat", "repeat")
self.scanlineImage:setFilter("nearest", "nearest")
end
self.scanlineQuad = love.graphics.newQuad(0, 0, width, height, 1, 3)
self.scanlineQuad = love.graphics.newQuad(0, 0, fullW, fullH, 1, 3)
end
-- Invert shader: the Boi's Club Games mark is dark ink; on this dark panel it
@@ -1790,21 +1912,23 @@ function RomImporter:draw()
}
]])
-- background
-- background (full window — unsafe edges stay painted)
col(PAL.bgBot)
love.graphics.rectangle("fill", 0, 0, width, height)
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.bgMesh)
-- Centered content container (max ~1440 scaled units on very wide windows)
-- with a responsive side gutter; every column below derives from these.
-- Origin is the safe-area top-left so chrome clears device insets.
local appW = math.min(width, 1440 * s)
local appX = (width - appW) / 2
local appX = ox + (width - appW) / 2
local padH = clamp(appW * 0.03, 12 * s, 26 * s)
local third = appW / 3
-- tricolor strip (Red | Blue | Yellow), 6px tall, with a soft downward bloom
local stripH = math.max(4, 6 * s)
local stripY = oy
local segs = {
{ PAL.red, appX, third },
{ PAL.blue, appX + third, third },
@@ -1812,11 +1936,11 @@ function RomImporter:draw()
}
love.graphics.setBlendMode("add")
for _, seg in ipairs(segs) do
fillGrad(seg[2], stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0)
fillGrad(seg[2], stripY + stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0)
end
love.graphics.setBlendMode("alpha")
for _, seg in ipairs(segs) do
col(seg[1]); love.graphics.rectangle("fill", seg[2], 0, seg[3], stripH)
col(seg[1]); love.graphics.rectangle("fill", seg[2], stripY, seg[3], stripH)
end
-- Footer (Boi's Club Games logo + trust warning), measured first so the
@@ -1838,7 +1962,7 @@ function RomImporter:draw()
math.min(330 * s, appW - 32 * s))
local logoScale = math.min(logoTargetW / logoW, height * 0.15 / logoH)
local logoDW, logoDH = logoW * logoScale, logoH * logoScale
local logoY = stripH + 14 * s
local logoY = stripY + stripH + 14 * s
-- Tab bar: R/B/Y/divider/MODS chips (label + underline on the active one),
-- with "N of 3 ready" right-aligned.
@@ -1868,7 +1992,7 @@ function RomImporter:draw()
local bannerBand = bannerActive and (bannerH + 20 * s) or 6 * s
local cX = appX + padH
local cW = appW - 2 * padH
local contentBottom = height - footerH - bannerBand
local contentBottom = oy + height - footerH - bannerBand
local cH = math.max(0, contentBottom - contentTop)
-- Page scroll. Everything under the tab bar -- panel, updater banner and
@@ -1879,14 +2003,14 @@ function RomImporter:draw()
-- the previous frame's measurement, the same one-frame settle the slot and
-- mod lists already rely on. While the page fits, `paged` is false and every
-- measurement below is what it always was.
local viewportH = math.max(0, height - contentTop)
local viewportH = math.max(0, oy + height - contentTop)
self._panelNaturalH = self._panelNaturalH or {}
local naturalH = (self._panelNaturalH[self.tab] or 0) + bannerBand + footerH
local paged, pageScroll, maxPage =
RomImporter.pageScrollFor(naturalH, viewportH, self.pageScroll)
self.pageScroll, self._pageMax = pageScroll, maxPage
-- read by the hit tests; a scrolled control is live only inside the viewport
pageBand = paged and { contentTop, height } or nil
pageBand = paged and { contentTop, oy + height } or nil
-- tab bar (rebuilds self.tabRects). Pinned: it is the launcher's navigation,
-- and it sits above the scrolling viewport.
@@ -2057,10 +2181,10 @@ function RomImporter:draw()
-- logo, over the split, with a gentle bob + gold glow + sweeping shine
local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s
local lx, ly = (width - logoDW) / 2, logoY + bob
local lx, ly = ox + (width - logoDW) / 2, logoY + bob
love.graphics.setBlendMode("add")
love.graphics.setColor(1, 0.85, 0.2, 0.16 + 0.12 * (0.5 + 0.5 * math.sin(pulse * 1.6)))
love.graphics.draw(self.logo, (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
love.graphics.draw(self.logo, ox + (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
logoScale * 1.05, logoScale * 1.05)
love.graphics.setBlendMode("alpha")
local shineW = 0.16
@@ -2093,11 +2217,11 @@ function RomImporter:draw()
-- save-slot rename modal (#205), drawn over everything
if self._rename then
col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height)
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
local dw = math.min(appW - 32 * s, 420 * s)
local dh = 128 * s
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local dy = oy + (height - dh) / 2
local rr = 12 * s
neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4)
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85)
@@ -2139,11 +2263,11 @@ function RomImporter:draw()
-- it is typed.
if self._indexPrompt then
col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height)
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
local dw = math.min(appW - 32 * s, 520 * s)
local dh = 168 * s
local dh = 176 * s
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local dy = oy + (height - dh) / 2
local rr = 12 * s
neonGlow(dx, dy, dw, dh, rr, PAL.modDot, 0.4)
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.9, 0.9)
@@ -2185,6 +2309,14 @@ function RomImporter:draw()
fy + 7 * s, math.max(1, 1.5 * s), fh - 14 * s)
end
-- PASTE under the field: a touch screen has no ctrl+V, and an index URL
-- is not something anyone retypes on a soft keyboard (#578). This rect
-- is the one click mousepressed honors while the prompt is up; pinned so
-- page-scroll banding never eats the tap.
self._indexPasteRect = self:_chipButton(fx + fw - 84 * s, fy + fh + 8 * s,
Strings("Paste"), { w = 84 * s, h = 28 * s, kind = "accent" })
self._indexPasteRect.pinned = true
love.graphics.setFont(self.hintFont)
col(PAL.warning)
printfB(Strings("Enter to add - Esc to cancel"),
@@ -2195,7 +2327,7 @@ function RomImporter:draw()
if self._modConfirm or self._modVersions or self._modReleaseNotes
or self._findDetails then
col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height)
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
end
if self._modConfirm then
local c = self._modConfirm
@@ -2203,7 +2335,7 @@ function RomImporter:draw()
local lineH = self.hintFont:getHeight() + 4 * s
local dh = 36 * s + (#c.lines) * lineH + 56 * s
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local dy = oy + (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2245,7 +2377,7 @@ function RomImporter:draw()
local dw = math.min(appW - 32 * s, 480 * s)
local dh = math.min(height - 48 * s, 360 * s)
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local dy = oy + (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2290,7 +2422,7 @@ function RomImporter:draw()
local dw = math.min(appW - 32 * s, 520 * s)
local dh = math.min(height - 48 * s, 420 * s)
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local dy = oy + (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.94, 0.94)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2347,7 +2479,7 @@ function RomImporter:draw()
listH = listN * rowH
dh = headerH + listH + footerH
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local dy = oy + (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.96, 0.96)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2356,7 +2488,7 @@ function RomImporter:draw()
love.graphics.setFont(self.slotNameFont)
col(PAL.white)
love.graphics.printf("Other versions " .. tostring(v.name),
love.graphics.printf("Other versions: " .. tostring(v.name),
dx + pad, dy + 10 * s, dw - pad * 2, "left")
love.graphics.setFont(self.hintFont)
local info = self:_modUpdateInfo(v.id)
@@ -2463,8 +2595,11 @@ function RomImporter:draw()
if self._hoverEnabled and not self._padCursorActive
and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then
if self._anyHover then
self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand")
love.mouse.setCursor(self.handCursor)
if not self.handCursor then
local ok, cursor = pcall(love.mouse.getSystemCursor, "hand")
if ok then self.handCursor = cursor end
end
if self.handCursor then love.mouse.setCursor(self.handCursor) end
else
resetPointerCursor(self)
end
@@ -2523,7 +2658,14 @@ end
function RomImporter:mousepressed(x, y, button)
if self._rename then return end -- the rename modal swallows all clicks
if self._indexPrompt then return end -- and so does the add-index prompt
-- The add-index prompt swallows clicks too, except its PASTE button: a
-- touch screen has no ctrl+V, so the button is the only paste path (#578).
if self._indexPrompt then
if button == 1 and inside(self._indexPasteRect, x, y) then
self:_pasteIndexUrl()
end
return
end
-- Mod confirm / versions / release-notes modals swallow clicks too.
if self._modConfirm then
if button ~= 1 then return end
@@ -2630,6 +2772,7 @@ function RomImporter:mousepressed(x, y, button)
self._modPress = nil -- and any half-started mod toggle press
self._pagePress = nil -- and any half-started page pan
self._findSearchFocus = false -- and the search caret, now off screen
self:_disarmTextInput()
-- Each tab is its own column of a different length; carrying one tab's
-- offset into another lands somewhere arbitrary.
self.pageScroll = 0
@@ -2751,11 +2894,14 @@ function RomImporter:mousepressed(x, y, button)
end
if inside(self.findRefreshRect, x, y) then
self._findSearchFocus = false
self:_disarmTextInput()
self:_refreshFind(true)
return
end
if inside(self.findSearchRect, x, y) then
self._findSearchFocus = true; return
self._findSearchFocus = true
self:_armTextInput()
return
end
for _, r in ipairs(self.findSourceRemoveRects or {}) do
if inside(r, x, y) then self:_removeIndex(r.id); return end
@@ -2783,7 +2929,10 @@ function RomImporter:mousepressed(x, y, button)
end
-- A press anywhere else on the tab drops the search caret, so the field does
-- not silently keep eating keystrokes once the player has moved on.
if self.tab == "find" then self._findSearchFocus = false end
if self.tab == "find" and self._findSearchFocus then
self._findSearchFocus = false
self:_disarmTextInput()
end
-- Nothing was hit. On a scrolling page that is a press on empty background,
-- which is the natural place to grab and pan from.
if armDrag and (self._pageMax or 0) > 0 then
@@ -2799,6 +2948,7 @@ function RomImporter:keypressed(key)
self:_commitRename()
elseif key == "escape" then
self._rename = nil
self:_disarmTextInput()
end
return
end
@@ -2809,13 +2959,11 @@ function RomImporter:keypressed(key)
self:_commitAddIndex()
elseif key == "escape" then
self._indexPrompt = nil
self:_disarmTextInput()
elseif key == "v" and (love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui")) then
-- an index URL is long and comes from a browser: typing it out by hand
-- is the difference between adding one and giving up
local ok, text = pcall(love.system.getClipboardText)
if ok and type(text) == "string" then
self._indexPrompt.text = self._indexPrompt.text .. text:gsub("%s", "")
end
self:_pasteIndexUrl()
end
return
end
@@ -2839,6 +2987,7 @@ function RomImporter:keypressed(key)
self.findScroll = 0
elseif key == "escape" or key == "return" or key == "kpenter" then
self._findSearchFocus = false
self:_disarmTextInput()
end
return
end
@@ -3392,6 +3541,27 @@ local MAX_SLOT_LABEL = 24
local MAX_INDEX_URL = 200
local MAX_FIND_QUERY = 48
-- Mobile LOVE only delivers love.textinput while setTextInput(true) is armed,
-- and arming it is also what raises the soft keyboard, so a cabled USB
-- keyboard is just as dead without it (#578). Every site that opens one of
-- the launcher's three text fields (_rename, _indexPrompt, _findSearchFocus)
-- arms through here, and every site that closes one disarms. Desktop has
-- text input on by default and the save editor hosted from this launcher
-- depends on it staying on (tools/save-editor/Kit.lua, #529), so disarm only
-- lowers on mobile -- setTextInput is global SDL state, not per-widget.
function RomImporter:_armTextInput()
if love.keyboard and love.keyboard.setTextInput then
pcall(love.keyboard.setTextInput, true)
end
end
function RomImporter:_disarmTextInput()
if not self.android then return end
if love.keyboard and love.keyboard.setTextInput then
pcall(love.keyboard.setTextInput, false)
end
end
function RomImporter:_beginRename(version, id)
local label
for _, slot in ipairs(self.slots[version] or {}) do
@@ -3399,12 +3569,14 @@ function RomImporter:_beginRename(version, id)
end
self._rename = { version = version, id = id, text = label or "" }
self._slotPress = nil -- cancel any armed click/drag on the list
self:_armTextInput()
end
function RomImporter:_commitRename()
local r = self._rename
if not r then return end
self._rename = nil
self:_disarmTextInput()
require("src.core.SaveData").renameSlot(r.version, r.id, r.text)
self:_refreshSlots(r.version)
end
@@ -3426,6 +3598,19 @@ function RomImporter:textinput(text)
self._rename.text = utf8Cap(self._rename.text .. text, MAX_SLOT_LABEL)
end
-- Clipboard into the index prompt, shared by ctrl/cmd+V and the prompt's
-- on-screen PASTE button (#578). Same rule as typed input: URLs never
-- contain a literal space, and a pasted one usually arrives with a stray
-- newline attached.
function RomImporter:_pasteIndexUrl()
if not self._indexPrompt then return end
local ok, text = pcall(love.system.getClipboardText)
if ok and type(text) == "string" then
self._indexPrompt.text =
utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL)
end
end
-- "+ New save slot": register an empty slot, make it active, relist, and pin the
-- scroll to the bottom (clamped next draw) so the new row is on screen.
function RomImporter:_newSlot(version)
@@ -3577,7 +3762,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h, paged)
local metaH = self.labelFont:getHeight()
local rowPadV = 10 * s
local chipBtnH = self.hintFont:getHeight() + 10 * s
-- LOADED sits top-right; Edit/Delete sit bottom-right row must fit both
-- LOADED sits top-right; Edit/Delete sit bottom-right -- row must fit both
-- without stacking on the same y (chip buttons are taller than the old text).
local loadedH = self.warningFont:getHeight() + 6 * s
local rowH = math.max(rowPadV * 2 + nameH + 4 * s + metaH,
@@ -4469,11 +4654,13 @@ end
-- would make the launcher's choice look like an endorsement.
function RomImporter:_promptAddIndex()
self._indexPrompt = { text = "" }
self:_armTextInput()
end
function RomImporter:_commitAddIndex()
local prompt = self._indexPrompt
self._indexPrompt = nil
self:_disarmTextInput()
if not prompt then return end
local ModIndex = require("src.mods.ModIndex")
local row, err = ModIndex.addSource(prompt.text or "")
+23 -7
View File
@@ -1,11 +1,26 @@
-- The 20-slot bag (BAG_ITEM_CAPACITY, constants/menu_constants.asm):
-- a distinct item id occupies one slot regardless of quantity; badges
-- live in the inventory table but are not bag items. save.bagOrder
-- keeps acquisition order like wBagItems (SELECT can reorder it).
-- The bag defaults to 20 slots (BAG_ITEM_CAPACITY,
-- constants/menu_constants.asm), but mods may replace that limit through
-- Data.constants.bagSize. A distinct item id occupies one slot regardless
-- of quantity; badges live in the inventory table but are not bag items.
-- save.bagOrder keeps acquisition order like wBagItems (SELECT can reorder
-- it).
local Bag = {}
Bag.CAPACITY = 20
local DEFAULT_CAPACITY = 20
-- `data` is injectable for the save editor and headless mod tests. Normal
-- gameplay may omit it because the loader merges mods into the Data
-- singleton before any item can be added. The fallback keeps old/stale
-- generated caches and isolated callers at the vanilla limit.
function Bag.capacity(data)
data = data or require("src.core.Data")
local configured = data and data.constants and data.constants.bagSize
if type(configured) == "number" and configured >= 1 then
return math.floor(configured)
end
return DEFAULT_CAPACITY
end
local function isBadge(id)
return id:find("BADGE", 1, true) ~= nil
@@ -55,9 +70,10 @@ end
-- Add qty of an item; returns false (and adds nothing) when a new slot
-- is needed and the bag is full, or when the stack would pass 99
-- (AddItemToInventory's per-slot quantity cap).
function Bag.add(save, id, qty)
function Bag.add(save, id, qty, data)
local inv = save.inventory
if not inv[id] and not isBadge(id) and Bag.slots(save) >= Bag.CAPACITY then
if not inv[id] and not isBadge(id)
and Bag.slots(save) >= Bag.capacity(data) then
return false
end
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
-1
View File
@@ -10,7 +10,6 @@
-- "ball" caller must throw it (battle only)
-- "learn", moveId caller must run the learn-move flow
local Pokemon = require("src.pokemon.Pokemon")
local Flags = require("src.script.Flags")
local Strings = require("src.core.Strings")
+10 -3
View File
@@ -384,9 +384,16 @@ function Tournament:update(dt)
else
self.pendingBattleOpts.seed = msg.seed
end
local battle, why = self.isHost
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
-- Split rather than `cond and newHost() or newGuest()`: the and/or
-- idiom truncates a call to its first result, so the second return
-- (the specific reason) was always dropped and every failure showed
-- the generic fallback instead of "same mods on both games" etc.
local battle, why
if self.isHost then
battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
else
battle, why = LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
end
if not battle then
self:exitWith(why or Strings("Link battle\ncan't start."))
return
+50
View File
@@ -86,8 +86,58 @@ local function mergeConflictLists(conflicts, incompatible)
return out
end
-- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs,
-- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises
-- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a
-- panel may draw must be scrubbed here -- the one place every mod manifest
-- passes through -- or a single mangled description crashes the whole MODS
-- panel instead of misrendering one card.
local function scrubUtf8(s)
if type(s) ~= "string" then return s end
s = s:gsub("^\239\187\191", "")
local out, i, n = {}, 1, #s
while i <= n do
local b = s:byte(i)
local len
if b < 0x80 then len = 1
elseif b >= 0xC2 and b <= 0xDF then len = 2
elseif b >= 0xE0 and b <= 0xEF then len = 3
elseif b >= 0xF0 and b <= 0xF4 then len = 4
end
local ok = len ~= nil and i + len - 1 <= n
if ok and len > 1 then
for j = i + 1, i + len - 1 do
local c = s:byte(j)
if c < 0x80 or c > 0xBF then ok = false; break end
end
if ok then
-- boundary lead bytes narrow their second byte: no overlongs
-- (E0/F0), no surrogates (ED), nothing past U+10FFFF (F4)
local b2 = s:byte(i + 1)
if (b == 0xE0 and b2 < 0xA0) or (b == 0xED and b2 > 0x9F)
or (b == 0xF0 and b2 < 0x90) or (b == 0xF4 and b2 > 0x8F) then
ok = false
end
end
end
if ok then
out[#out + 1] = s:sub(i, i + len - 1)
i = i + len
else
i = i + 1
end
end
return table.concat(out)
end
function Manifest.validate(raw, path)
assert(type(raw) == "table", "manifest must be an object")
-- scrubbed in place so every later reader agrees, including the launcher's
-- badge derivation, which reads raw.category rather than the validated copy
raw.name = scrubUtf8(raw.name)
raw.version = scrubUtf8(raw.version)
raw.description = scrubUtf8(raw.description)
raw.category = scrubUtf8(raw.category)
assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"),
"manifest id must contain only letters, numbers, _ or -")
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
+6 -4
View File
@@ -1186,11 +1186,13 @@ R.field = {
nest = f.opt(f.any) },
flyOrder = f.list(f.str),
-- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle
-- back pic, the catch tutorial's old man, and the front pic the intro,
-- trainer card and Hall of Fame share. Every key is optional so a
-- conversion can replace one pic and inherit the rest.
-- back pic, the catch tutorial's old man, Yellow's PROF.OAK variant of
-- it (#557), and the front pic the intro, trainer card and Hall of Fame
-- share. Every key is optional so a conversion can replace one pic and
-- inherit the rest.
playerPics = f.rec{
back = f.opt(f.str), demoBack = f.opt(f.str), front = f.opt(f.str) },
back = f.opt(f.str), demoBack = f.opt(f.str),
oakBack = f.opt(f.str), front = f.opt(f.str) },
-- the new-game and boot config a total conversion replaces
boot = f.rec{
startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)),
+13 -1
View File
@@ -46,20 +46,32 @@ end
-- opts.kind: "battle" | "intro" | "trainer_card" | "hof"
-- opts.demo: the catch tutorial, where the old man fights in the player's
-- place and stands in for the back pic
-- opts.oakDemo: the Yellow variant of that demo (BATTLE_TYPE_PIKACHU), where
-- PROF.OAK fights in the player's place behind his own back pic
-- opts.battle: the live battle, for kind == "battle"
-- Returns path, trueColor.
function Sprites.playerPath(data, side, opts)
opts = opts or {}
side = side == "back" and "back" or "front"
-- one key per pic, so a conversion can replace the back and inherit the
-- rest; fieldValue folds data.field over FieldDefaults per key
-- rest; fieldValue folds data.field over FieldDefaults per key. The two
-- demo keys mirror LoadPlayerBackPic's wBattleType branch (#557).
local key = side == "front" and "front"
or (opts.oakDemo and "oakBack")
or (opts.demo and "demoBack" or "back")
local path = FieldDefaults.fieldValue(data, "playerPics", key)
-- ProfOakPicBack is a Yellow-only rip, so a cache imported before it
-- existed has no file there; fall back to the old man rather than hand a
-- missing path to getImage (#557)
if key == "oakBack" and path
and not require("src.render.Assets").exists(path) then
path = FieldDefaults.fieldValue(data, "playerPics", "demoBack")
end
local ctx = {
side = side,
kind = opts.kind or "battle",
demo = opts.demo and true or false,
oakDemo = opts.oakDemo and true or false,
battle = opts.battle,
trueColor = false,
data = data,
+158 -24
View File
@@ -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
+1 -1
View File
@@ -120,7 +120,7 @@ end
-- shape (love.graphics.newCanvas), so accept either and nothing else.
local function isCanvas(v)
if type(v) == "userdata" then
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
end
if type(v) == "table" then
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
+325 -78
View File
@@ -32,7 +32,7 @@ Renderer.MAX_UI_HEIGHT = 576
-- would otherwise take the frame down with it.
local function isCanvas(v)
if type(v) == "userdata" then
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
end
if type(v) == "table" then
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
@@ -127,6 +127,35 @@ function Renderer:fitScale()
return math.max(1, math.floor(math.min(pw / w, ph / h)))
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
-- Only follow the zoom when a world is actually on screen. 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.
if not self.worldActive 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
function Renderer:uiSize()
return self.uiWidth or self.WIDTH, self.uiHeight or self.HEIGHT
@@ -200,8 +229,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 +253,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)
@@ -474,6 +559,73 @@ local function withTrueColor(zoneList, pass)
return merged
end
-- Palette-correct blit of `canvas` at (sx, sy) LOVE-unit scales into origin
-- (bx, by), scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/
-- zoneSy convert zone coords (canvas-space) into screen units. Public so a
-- render.compose mod can composite the world/UI canvases into its own layout.
function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then
love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
love.graphics.setScissor()
return
end
love.graphics.setShader(shader)
-- a colors == false zone is the trueColor opt-out: its rect draws with
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
-- list never toggles and issues exactly the calls it always did.
local bare = false
for _, z in ipairs(zoneList) do
local plain = z.colors == false
if plain ~= bare then
bare = plain
love.graphics.setShader(not plain and shader or nil)
end
if not plain then PaletteFX.sendColors(shader, z.colors) end
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
z.w * zoneSx, z.h * zoneSy,
boxX, boxY, boxW, boxH, dpiX, dpiY) then
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
end
love.graphics.setScissor()
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)
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
@@ -494,6 +646,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.
@@ -506,6 +675,36 @@ function Renderer:endFrame(zones, worldZones)
zones = withTrueColor(zones, "ui")
worldZones = withTrueColor(worldZones, "world")
-- render.compose: hand a mod the finished world + UI canvases (and their
-- SGB zones), the frame metrics, Renderer:blitCanvas and the SecondScreen
-- bridge, letting it lay the two passes out however it likes -- e.g. as two
-- stacked Game Boy screens, or driving one onto a second physical display.
-- The mod returns true to take over the whole window; anything else (or no
-- mod wrapping the hook) falls through to the normal composite below.
if Runtime.wantsHook("render.compose") then
local ctx = {
renderer = self,
worldCanvas = self.worldCanvas, uiCanvas = self.canvas,
worldOverride = self.worldOverride,
worldActive = self.worldActive and true or false,
zones = zones, worldZones = worldZones,
ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy,
vpw = vpw, vph = vph, uiw = uiw, uih = uih,
scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY,
secondScreen = require("src.render.SecondScreen"),
}
if Runtime.call("render.compose", function() return false end, self, ctx) == true then
self.worldActive = false
self.uprightActive = false
self.worldOverride = nil
PaletteFX.setPass(nil)
return {
width = ww, height = wh, gameX = ox, gameY = oy,
gameWidth = vpw, gameHeight = vph, scale = Sp, dpiX = dpiX, dpiY = dpiY,
}
end
end
-- A post-process pipeline needs the whole composite in a canvas for the
-- same reason GBC FX does, so either one alone is enough to take the
-- present path; with neither, the frame draws straight to the screen
@@ -541,7 +740,12 @@ 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
-- 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
@@ -560,38 +764,12 @@ function Renderer:endFrame(zones, worldZones)
})
end
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
-- convert zone coords (canvas-space) into screen units.
-- see Renderer:blitCanvas; bound here to the frame's dpi so the composite
-- call sites below stay unchanged.
local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then
love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
love.graphics.setScissor()
return
end
love.graphics.setShader(shader)
-- a colors == false zone is the trueColor opt-out: its rect draws with
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
-- list never toggles and issues exactly the calls it always did.
local bare = false
for _, z in ipairs(zoneList) do
local plain = z.colors == false
if plain ~= bare then
bare = plain
love.graphics.setShader(not plain and shader or nil)
end
if not plain then PaletteFX.sendColors(shader, z.colors) end
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
z.w * zoneSx, z.h * zoneSy,
boxX, boxY, boxW, boxH, dpiX, dpiY) then
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
end
love.graphics.setScissor()
love.graphics.setShader()
return self:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
end
if self.worldOverride then
@@ -602,7 +780,12 @@ function Renderer:endFrame(zones, worldZones)
-- runs, so dialogs, menus and the HUD sit on top as usual.
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
local loveMajor = love.getVersion()
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY)
else
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
end
love.graphics.setScissor()
-- the screen-space overlays the flat path draws over its composite
local fade = self.worldFadeAlpha
@@ -611,9 +794,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
@@ -688,14 +868,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()
+59
View File
@@ -0,0 +1,59 @@
-- Bridge to native secondary-display output (Android Presentation). The C
-- functions live in mobile/android/love/src/jni/love/src/common/android.cpp.
-- Everything is guarded: off Android, or if the symbols cannot be resolved,
-- this stays inert and the renderer keeps the in-window stacked layout.
local SecondScreen = {}
local C = nil
local function log(msg)
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
end
do
local ok, ffi = pcall(require, "ffi")
if not (ok and ffi) then
log("ffi unavailable (not LuaJIT); second display disabled")
else
pcall(ffi.cdef, [[
int love_android_secondary_ready();
void love_android_push_secondary(const void *rgba, int w, int h);
void love_android_secondary_enable(int on);
]])
local okLib, lib = pcall(ffi.load, "love")
if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then
C = lib
log("bridge linked via ffi.load('love')")
elseif pcall(function() return ffi.C.love_android_secondary_ready end) then
C = ffi.C
log("bridge linked via default namespace")
else
log(("bridge symbols not found (ffi.load ok=%s); second display disabled")
:format(tostring(okLib)))
end
end
end
function SecondScreen.usable()
return C ~= nil
end
function SecondScreen.available()
if not C then return false end
local ok, r = pcall(C.love_android_secondary_ready)
return ok and r ~= 0
end
function SecondScreen.push(imageData, w, h)
if not C or not imageData then return false end
return pcall(function()
C.love_android_push_secondary(imageData:getFFIPointer(), w, h)
end)
end
function SecondScreen.setEnabled(on)
if not C then return end
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
end
return SecondScreen
+32
View File
@@ -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
@@ -177,6 +178,13 @@ 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
if self.auto then
if not self.autoStarted then
@@ -244,6 +252,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 +267,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 +304,7 @@ function TextBox:update(dt)
if conts and conts[nextIdx] then
-- pokered <CONT>: ▼ + WaitForTextScrollButtonPress before scroll
self.waiting = true
self.preWait = Timing.TEXT_PRE_ADVANCE
self.contAdvance = true
else
self.lineIndex = nextIdx
@@ -290,6 +312,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 +323,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
+92 -7
View File
@@ -1,17 +1,27 @@
-- 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
-- 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,26 +46,39 @@ 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
function Transition:draw()
local alpha = self.t / self.frames
local len = (self.phase == "out") and self.frames or self.framesIn
local alpha = (len and len > 0) and (self.t / len) or 1
if self.phase == "in" then alpha = 1 - alpha end
-- Survey zoom draws the overworld into a window-filling world canvas
-- while the UI pass stays the classic 160x144 letterbox. A rect on the
@@ -101,4 +124,66 @@ 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.
local FADE_STEP_FRAMES = 8
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
+10
View File
@@ -10,6 +10,13 @@ local Zoom = {}
Zoom.offset = 0
-- Survey zoom (zooming out past FIT, which renders connected neighbor maps)
-- is the port's most expensive optional extra. The performance tier sets
-- this false on LOW hardware (Game:applyOptions); offsetRange then floors
-- the range at FIT so the option row, hotkey, and mouse wheel all stop at
-- close-up. Nil/true keeps the historical full range.
Zoom.allowSurvey = true
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
-- through 2× fit). zoom.range may widen or shrink the window.
function Zoom.offsetRange(S)
@@ -21,6 +28,9 @@ function Zoom.offsetRange(S)
hi = math.floor(tonumber(hi) or S)
if lo > hi then lo, hi = hi, lo end
end
-- LOW performance tier: no survey (negative offsets), even if a mod's
-- zoom.range widened it. == false so nil/true stays permissive.
if Zoom.allowSurvey == false and lo < 0 then lo = 0 end
return lo, hi
end
+25 -4
View File
@@ -210,11 +210,12 @@ end
-- text (label or literal; {RAM:wStringBuffer} becomes the item name);
-- pass false when the script shows its own received-text row.
function Commands.give_item(ctx, itemId, count, gotText)
-- the 20-slot bag can refuse (BAG_ITEM_CAPACITY): say so and halt
-- the bag can refuse at its configured capacity (20 in vanilla): halt
-- the script, so later set_flag rows don't burn the gift -- make
-- room and talk again, like the original (pokered's `jr nc, .bag_full`
-- skips the received text entirely when AddItemToInventory refuses)
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
if not require("src.inventory.Bag").add(
ctx.save, itemId, count or 1, ctx.game.data) then
Commands.show_text(ctx, ctx.game.data.text
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
return math.huge
@@ -280,7 +281,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
@@ -769,7 +782,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
+151 -27
View File
@@ -6,6 +6,7 @@
local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu")
local ChoiceBox = require("src.ui.ChoiceBox")
local Input = require("src.core.Input")
local Strings = require("src.core.Strings")
@@ -13,16 +14,18 @@ local BindingsMenu = setmetatable({}, { __index = ListMenu })
BindingsMenu.__index = BindingsMenu
-- Input.lua's map, primary key first where several keys share a button.
-- `pad` is the default SDL gamecontroller button (see Input.lua); shown
-- on the SELECT row so controller Back/View is discoverable (#73).
-- `pad` mirrors DEFAULT_GAMEPAD_BINDINGS in src/core/Input.lua row for
-- row; keep the two in sync. Every row shows its key and pad so the
-- controller side is discoverable (#73, #589), and the swap in
-- storeBinding leans on each row holding a value in both slots.
local BUTTONS = {
{ id = "up", label = "UP", key = "up" },
{ id = "down", label = "DOWN", key = "down" },
{ id = "left", label = "LEFT", key = "left" },
{ id = "right", label = "RIGHT", key = "right" },
{ id = "a", label = "A", key = "z" },
{ id = "b", label = "B", key = "x" },
{ id = "start", label = "START", key = "escape" },
{ id = "up", label = "UP", key = "up", pad = "dpup" },
{ id = "down", label = "DOWN", key = "down", pad = "dpdown" },
{ id = "left", label = "LEFT", key = "left", pad = "dpleft" },
{ id = "right", label = "RIGHT", key = "right", pad = "dpright" },
{ id = "a", label = "A", key = "z", pad = "a" },
{ id = "b", label = "B", key = "x", pad = "b" },
{ id = "start", label = "START", key = "escape", pad = "start" },
{ id = "select", label = "SELECT", key = "tab", pad = "back" },
}
@@ -41,14 +44,33 @@ local function boundPad(overlay, def)
return def.pad
end
-- Key column for every row. SELECT also appends "/PAD" (default BACK)
-- so controller Select/View is visible without opening a second legend.
-- The right column is KEY/PAD (e.g. "Z/A"). The row is 20 tiles and the
-- widest label ("SELECT") ends at x=64, so each half is clamped to 5
-- glyphs: 5+1+5 right-aligned at x=152 starts no further left than x=64.
-- SDL names longer than 5 get a fixed short form before the clamp.
local KEY_SHORT = {
escape = "ESC", backspace = "BKSP", ["return"] = "ENTER",
kpenter = "ENTER", space = "SPACE",
}
local PAD_SHORT = {
dpup = "D-UP", dpdown = "D-DN", dpleft = "D-LT", dpright = "D-RT",
leftshoulder = "LB", rightshoulder = "RB",
leftstick = "LS", rightstick = "RS", guide = "GUIDE",
}
local function shortName(name, shorts)
local s = shorts[name]
if s then return s end
s = name:upper()
return #s > 5 and s:sub(1, 5) or s
end
-- Right column for every row: effective key and pad together, so a
-- controller player can read the whole map without a second legend (#589).
local function boundRight(overlay, def)
local key = boundKey(overlay, def)
if def.id ~= "select" then return key:upper() end
local key = shortName(boundKey(overlay, def), KEY_SHORT)
local pad = boundPad(overlay, def)
if pad then return (key .. "/" .. pad):upper() end
return key:upper()
if pad then return key .. "/" .. shortName(pad, PAD_SHORT) end
return key
end
function BindingsMenu.new(game)
@@ -61,9 +83,17 @@ function BindingsMenu.new(game)
items[i] = { label = Strings(def.label),
right = boundRight(overlay, def), button = def }
end
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
BindingsMenu)
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {
-- 6 rows leaves the bottom two lines free for the hint; a clear or
-- reset nobody can see on screen may as well not exist (#589)
rows = 6,
footer = Strings("SELECT:CLEAR ROW\nSTART:RESET ALL"),
}), BindingsMenu)
self.onChoose = function(item) self:beginCapture(item) end
-- SELECT deletes one row's rebind: dropping the overlay entry is enough
-- because Input:applyBindings rebuilds the whole map from the defaults
-- on every call (#589)
self.onSelectKey = function(item) self:clearBinding(item) end
-- A rebind reaches Input only when this screen closes (#510). The menu
-- steers by the live map, so applying "B = Z" the instant it was captured
-- turned the player's next confirm press into a cancel and shut the
@@ -82,12 +112,28 @@ function BindingsMenu:commitBindings()
if opts then Input:applyBindings(opts.bindings) end
end
-- the capture handlers are per-instance slots, so Game's raw-input
-- routing only ever sees this screen while a capture is armed
-- The capture handlers are per-instance slots, so Game's raw-input
-- routing only ever sees this screen while a capture is armed. A capture
-- no longer commits on the press: it commits when that press is RELEASED,
-- and a second key or pad button going down while the first is still held
-- cancels instead. That gives a bare controller a way to back out of an
-- armed row, where Escape cannot help (#589).
function BindingsMenu:beginCapture(item)
self.capture = item
self.pending = nil
self.onKeyPressed = BindingsMenu.captureKey
self.onGamepadPressed = BindingsMenu.capturePad
self.onKeyReleased = BindingsMenu.captureKeyRelease
self.onGamepadReleased = BindingsMenu.capturePadRelease
end
function BindingsMenu:endCapture()
self.capture = nil
self.pending = nil
self.onKeyPressed = nil
self.onGamepadPressed = nil
self.onKeyReleased = nil
self.onGamepadReleased = nil
end
-- Escape is the capture's way out, so it is never captured: every other
@@ -95,23 +141,64 @@ end
-- to bind something (#510). Escape stays START in Input's default map,
-- which no rebind removes, so reserving it costs the player nothing.
function BindingsMenu:captureKey(key)
if key == "escape" then return self:storeBinding("key", nil) end
self:storeBinding("key", key)
if key == "escape" or self.pending then return self:endCapture() end
self.pending = { slot = "key", value = key }
end
function BindingsMenu:capturePad(button)
self:storeBinding("pad", button)
if self.pending then return self:endCapture() end
self.pending = { slot = "pad", value = button }
end
-- Game forwards every release to Input BEFORE these hooks (see
-- Game:keyreleased): the capture observes releases, it never owns them,
-- so Input's held-state stays honest for keys it saw go down before the
-- capture armed. A release that does not match the pending input (the
-- press that armed the row, a cancelled capture's stragglers) is noise.
function BindingsMenu:captureKeyRelease(key)
local p = self.pending
if p and p.slot == "key" and p.value == key then
self:storeBinding("key", key)
end
end
function BindingsMenu:capturePadRelease(button)
local p = self.pending
if p and p.slot == "pad" and p.value == button then
self:storeBinding("pad", button)
end
end
function BindingsMenu:storeBinding(slot, value)
local item = self.capture
self.capture = nil
self.onKeyPressed = nil
self.onGamepadPressed = nil
self:endCapture()
local game = self.game
if not (item and value and game.save and game.save.options) then return end
local opts = game.save.options
opts.bindings = opts.bindings or {}
-- Swap, never steal (#589): when the captured input is another row's
-- effective binding in this slot, that row inherits this row's previous
-- binding. Every BUTTONS row has a default in both slots, so `prev`
-- always exists: no row goes empty and no input serves two rows.
-- Default key ALIASES (W beside Up, Space beside Z; DEFAULT_BINDINGS in
-- Input.lua) are not effective bindings, so capturing one costs the
-- other row a spare alias, never its shown key.
local effective = (slot == "key") and boundKey or boundPad
local prev = effective(opts.bindings, item.button)
if value ~= prev then
for _, other in ipairs(self.items) do
if other ~= item and effective(opts.bindings, other.button) == value then
local ob = opts.bindings[other.button.id]
if type(ob) ~= "table" then
ob = { key = type(ob) == "string" and ob or nil }
end
ob[slot] = prev
opts.bindings[other.button.id] = ob
other.right = boundRight(opts.bindings, other.button)
break
end
end
end
local b = opts.bindings[item.button.id]
if type(b) ~= "table" then
-- keep a direct-edited plain key string when only the pad changes
@@ -123,18 +210,55 @@ function BindingsMenu:storeBinding(slot, value)
if game.writeOptions then game:writeOptions() end
end
-- SELECT: forget one row's rebind and fall back to the defaults. #510's
-- deferral still holds: only options change here, the live map catches up
-- in commitBindings on close.
function BindingsMenu:clearBinding(item)
local game = self.game
local opts = game and game.save and game.save.options
if not (opts and opts.bindings and opts.bindings[item.button.id]) then
return
end
opts.bindings[item.button.id] = nil
item.right = boundRight(opts.bindings, item.button)
if game.writeOptions then game:writeOptions() end
end
-- START: confirm, then drop the whole overlay (#589). The footer doubles
-- as the prompt while the YES/NO box is up, the same bottom-line pattern
-- the mart and PC screens use.
function BindingsMenu:confirmReset()
local game = self.game
local hint = self.footer
self.footer = Strings("RESET ALL BINDINGS?")
game.stack:push(ChoiceBox.new(game, function(yes)
self.footer = hint
if not yes then return end
local opts = game.save and game.save.options
if opts then opts.bindings = nil end
for _, it in ipairs(self.items) do
it.right = boundRight(nil, it.button)
end
if game.writeOptions then game:writeOptions() end
end, { defaultNo = true }))
end
function BindingsMenu:update(dt)
if self.capture then return end -- the raw capture owns the input
if self.game.input:wasPressed("start") then
return self:confirmReset()
end
ListMenu.update(self, dt)
end
function BindingsMenu:draw()
ListMenu.draw(self)
if self.capture then
Font.drawBox(1, 6, 18, 5)
Font.drawBox(1, 6, 18, 6)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("PRESS A BUTTON"), 24, 60)
Font.draw(Strings("ESC TO CANCEL"), 24, 72)
Font.draw(Strings("RELEASE TO SET"), 24, 72)
Font.draw(Strings("ESC/2ND CANCELS"), 24, 84)
love.graphics.setColor(1, 1, 1, 1)
end
end
+4
View File
@@ -66,6 +66,7 @@ local function withdraw(game)
end
game.stack:push(ListMenu.new(game,
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
local mon = box[item.value]
if not mon then return end
@@ -112,6 +113,7 @@ local function deposit(game)
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
local mon = game.save.party[item.value]
if not mon then return end
@@ -157,6 +159,7 @@ local function release(game)
end
game.stack:push(ListMenu.new(game,
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(_, list)
local mon = box[list.index]
if not mon then return end
@@ -189,6 +192,7 @@ local function changeBox(game)
})
end
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
-- the original asks BEFORE switching ("When you change a #MON
-- BOX, data will be saved. OK?"); declining aborts the change
+27 -4
View File
@@ -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
@@ -25,6 +26,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 +46,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.game and self.game.renderer
if r and r.setUIAnchor then
r:setUIAnchor(tx * 8, ty * 8, tw * 8, th * 8, "bottom")
end
Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
+4
View File
@@ -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)
+18
View File
@@ -78,6 +78,10 @@ function ListMenu.new(game, title, items, opts)
-- bottom text box -- same row budget as the mart, without the money box.
self.messageBox = opts.messageBox
self.money = opts.money -- () -> current money for the box
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): both PC sessions hold the flag
-- for their whole run (engine/menus/pc.asm, engine/menus/players_pc.asm),
-- so their lists opt out of the A/B beep the same way Menu's noSound does
self.noSound = opts.noSound or false
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
return self
end
@@ -101,6 +105,17 @@ local function syncScroll(self)
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
end
-- HandleMenuInput_ (home/window.asm) replays SFX_PRESS_AB for any watched
-- A or B press; DisplayListMenuID's mask is PAD_A | PAD_B | PAD_SELECT
-- (home/list_menu.asm), so the SELECT swap key stays silent (#570)
local function beep(self)
-- game.data is nil under the UI harnesses that drive a list with a stub
-- game (tests/engine/rebind_capture_bug510.lua), where there is no audio
-- cache to play out of
if self.noSound or not (self.game and self.game.data) then return end
require("src.core.Sound").play(self.game.data, "Press_AB")
end
-- edge press or key-repeat tick for a held direction
local function navPressed(self, dir)
if dir == "up" then
@@ -126,6 +141,7 @@ function ListMenu:update(dt)
local input = self.game.input
if #self.items == 0 then
if input:wasPressed("a") or input:wasPressed("b") then
beep(self)
self.game.stack:pop()
if self.onCancel then self.onCancel() end
end
@@ -148,10 +164,12 @@ function ListMenu:update(dt)
elseif self.onSelectKey and input:wasPressed("select") then
self.onSelectKey(self.items[self.index], self)
elseif input:wasPressed("b") then
beep(self)
self.game.stack:pop()
if self.onCancel then self.onCancel() end
return
elseif input:wasPressed("a") then
beep(self)
local item = self.items[self.index]
if self.onChoose then
self.onChoose(item, self)
+29 -4
View File
@@ -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,24 +107,46 @@ 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))
or #self.items
-- Row Y: pokered's boxed menus anchor the choices to the BOTTOM interior
-- row and let any slack fall as a blank row under the top edge --
-- draw_start_menu.asm (TextBoxBorder 10,0, then hlcoord 12,2 /
-- wTopMenuItemY 2), players_pc.asm and bills_pc.asm all do the same. The
-- bottom border is ty + th - 1, so the last choice sits on ty + th - 2 and
-- row r counts back up from there. Anchoring from the top instead only
-- agrees when th is exactly visible * rowStep + 2, which is Menu.new's
-- default but NOT what a caller sizing its own box passes: BagMenu's
-- USE/TOSS is th = 5 for two choices (#284, matching text_boxes.asm's
-- USE_TOSS_MENU_TEMPLATE rows 10..14), and a top anchor pushed TOSS onto
-- the bottom border (#564, #572).
for row = 1, visible do
local item = self.items[self.scroll + row]
if not item then break end
Font.draw(item.label, (self.tx + 2) * 8,
(self.ty + row * self.rowStep - (self.rowStep - 1)) * 8)
(self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8)
end
local cursorRow = self.index - self.scroll
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
(self.ty + cursorRow * self.rowStep - (self.rowStep - 1)) * 8)
(self.ty + self.th - 2 - (visible - cursorRow) * self.rowStep) * 8)
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
-- use, sat on the bottom border like TextBox's page-advance cursor
-- use, sat on the bottom border like TextBox's page-advance cursor. It
-- has to be the border row, not ty + th - 2: that is the last interior
-- row, which the last choice now occupies, and Menu.new widens the box to
-- widest + 3 so tx + tw - 2 is exactly that label's final glyph (#564).
if self.maxVisible and self.scroll + self.maxVisible < #self.items then
Font.drawCode(Theme.moreArrow, (self.tx + self.tw - 2) * 8,
(self.ty + self.th - 2) * 8)
(self.ty + self.th - 1) * 8)
end
love.graphics.setColor(1, 1, 1, 1)
end
+76
View File
@@ -19,7 +19,9 @@ 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")
local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows")
@@ -155,6 +157,40 @@ 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 },
{ id = "ruleset", label = Strings("RULESET"),
value = function(g) return rulesetName(g) end,
step = function(g, dir)
@@ -202,6 +238,21 @@ local function buildRows(game)
require("src.core.Music").setFilterLevel(o.musicFilter)
return true
end },
-- Heads the port's display group: one tier that scales the heavy extras
-- (TILT / GBC FX / survey ZOOM) and the FPS ceiling for weaker devices.
-- AUTO picks a default from the hardware; every tier is overridable.
-- Re-applies live so the extras clamp (or, on a higher tier, restore to
-- the player's stored TILT / GBC FX / ZOOM) the moment the row changes.
{ id = "performance", label = Strings("PERFORMANCE"),
value = function(g)
return Strings(Performance.label(g.save.options.performance))
end,
step = function(g, dir)
local o = g.save.options
o.performance = Performance.cycle(o.performance, dir)
g:applyOptions(o)
return true
end },
{ id = "colors", label = Strings("COLORS"),
value = function(g)
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
@@ -282,6 +333,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 RES"),
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.
@@ -432,10 +497,21 @@ function OptionsMenu:update(dt)
elseif row and row.step then
changed = row.step(self.game, dir) and true or false
elseif input:wasPressed("a") then -- CANCEL
-- DisplayOptionMenu .exitMenu (engine/menus/main_menu.asm) is the
-- only spot in this menu that plays SFX_PRESS_AB: A on a setting row
-- and the Left/Right toggles stay silent (#570). game.data is nil
-- under the stub games the UI harnesses drive.
if self.game.data then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
self.game.stack:pop()
if self.onCancel then self.onCancel() end
end
elseif input:wasPressed("b") or input:wasPressed("start") then
-- .exitMenu again: B and START leave the same way, sound and all
if self.game.data then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
self.game.stack:pop()
if self.onCancel then self.onCancel() end
end
+8
View File
@@ -310,6 +310,14 @@ function PartyMenu:update(dt)
local input = self.game.input
local party = self.party or self.game.save.party
-- HandlePartyMenuInput (home/pokemon.asm) and the field-move submenu
-- (engine/menus/start_sub_menus.asm .chosePokemon) both run through
-- HandleMenuInput_, which beeps SFX_PRESS_AB on any A or B press (#570).
-- game.data is nil under the stub games the UI harnesses drive.
if self.game.data and (input:wasPressed("a") or input:wasPressed("b")) then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
if self.submenu then
local n = #self.subItems
if input:wasPressed("up") then
+4 -1
View File
@@ -73,10 +73,11 @@ local function withdraw(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag")
if not Bag.add(game.save, item.value, qty) then
if not Bag.add(game.save, item.value, qty, game.data) then
list.footer = Strings("You can't carry\nany more items.")
return
end
@@ -110,6 +111,7 @@ local function deposit(game)
end
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
if pcFull(game, pc, item.value) then
@@ -130,6 +132,7 @@ local function toss(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
local def = game.data.items[item.value]
if (def and def.keyItem) or item.value:find("^HM_") then
+11 -3
View File
@@ -50,11 +50,13 @@ function PokedexMenu.new(game, opts)
footer = Strings("SEEN %d OWNED %d", seen, owned),
pageJump = true, -- Left/Right page jumps like the original
onCancel = opts.onCancel, -- B returns to the start menu when opened from it
onChoose = function(item)
onChoose = function(item, dexList)
if not item.value then return end
-- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm
-- PokedexMenuItemsText); CRY keeps the side menu open like the
-- original, QUIT returns to the list
-- original. B is what returns to the list: HandlePokedexSideMenu
-- hands back b=2 for B and b=1 for QUIT, and ShowPokedexMenu sends
-- b=1 to .exitPokedex, so QUIT closes the whole Pokédex (#571)
local Menu = require("src.ui.Menu")
local Screens = require("src.ui.Screens")
local entries = {
@@ -91,7 +93,13 @@ function PokedexMenu.new(game, opts)
or Strings("Printer error!\n%s", tostring(err))))
end }
end
entries[#entries + 1] = { label = Strings("QUIT") }
entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function()
-- .exitPokedex: drop the dex list too and hand back to whoever
-- opened it (the start menu, whose saved cursor is still on
-- POKéDEX -- wBattleAndStartSavedMenuItem)
dexList:close()
if opts.onCancel then opts.onCancel() end
end }
game.stack:push(Menu.new(game, entries,
{ tx = 12, ty = 8, tw = 8,
th = #entries * 2 + 2 }))
+1 -1
View File
@@ -67,7 +67,7 @@ local function buy(game, stock)
list.footer = notEnough
return
end
if not Bag.add(game.save, item.value, qty) then
if not Bag.add(game.save, item.value, qty, game.data) then
list.footer = txt(game, "_PokemartItemBagFullText",
Strings("You can't carry\nany more items."))
return
+6 -1
View File
@@ -133,7 +133,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)
+6
View File
@@ -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.
--
+13 -11
View File
@@ -109,34 +109,36 @@ function Editor.update(_dt)
end
function Editor.draw()
local ww, wh = love.graphics.getDimensions()
local SafeArea = require("src.core.SafeArea")
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))
Editor.rects = {}
-- radial-ish navy field (two stacked fills; matches launcher atmosphere)
col(PAL.bgBot)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
col(PAL.bgTop, 0.85)
love.graphics.circle("fill", ww * 0.5, wh * 0.15, math.max(ww, wh) * 0.55)
love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55)
local pad = 18 * s
local barH = 56 * s
local btnH = 40 * s
local btnW = 100 * s
-- top bar
-- top bar (inside the safe area so it clears the notch / status bar)
col(PAL.card, 0.92)
love.graphics.rectangle("fill", 0, 0, ww, barH + pad)
love.graphics.rectangle("fill", 0, 0, fullW, oy + barH + pad)
col(PAL.stroke, 0.35)
love.graphics.setLineWidth(1)
love.graphics.line(0, barH + pad, ww, barH + pad)
love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad)
love.graphics.setFont(Editor.fonts.title)
col(PAL.white)
love.graphics.print("Touch Controls", pad, pad + 4 * s)
love.graphics.print("Touch Controls", ox + pad, oy + pad + 4 * s)
-- Done / Reset
local done = { x = ww - pad - btnW, y = pad + (barH - btnH) / 2,
local done = { x = ox + ww - pad - btnW, y = oy + pad + (barH - btnH) / 2,
w = btnW, h = btnH }
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
Editor.rects.done, Editor.rects.reset = done, reset
@@ -156,9 +158,9 @@ function Editor.draw()
chromeBtn(done, "Done", PAL.green)
-- enable toggle card
local cardY = barH + pad + 14 * s
local cardY = oy + barH + pad + 14 * s
local cardH = 64 * s
local cardX, cardW = pad, ww - 2 * pad
local cardX, cardW = ox + pad, ww - 2 * pad
col(PAL.card, 0.88)
roundRect("fill", cardX, cardY, cardW, cardH, 12 * s)
col(PAL.stroke, 0.4)
@@ -190,7 +192,7 @@ function Editor.draw()
local hint = on
and "Drag each button to reposition. Layout is saved when you tap Done."
or "Controls are hidden in-game. Enable them to show and edit the layout."
love.graphics.printf(hint, pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
love.graphics.printf(hint, ox + pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
-- the overlay itself (preview mode; dimmed when disabled)
TouchControls:draw()
+6 -1
View File
@@ -89,6 +89,11 @@ local PLAYER_SPRITES = {
local PLAYER_PICS = {
back = "assets/generated/battle/redb.png",
demoBack = "assets/generated/battle/oldmanb.png",
-- LoadPlayerBackPic keys the demo pic off wBattleType, not off "is a
-- demo": BATTLE_TYPE_PIKACHU (Yellow's Pallet catch scene) puts PROF.OAK
-- in the player's place with his own back pic, not the old man's (#557).
-- Red/Blue never reach it -- only the Yellow script names that thrower.
oakBack = "assets/generated/battle/profoakb.png",
front = "assets/generated/trainer_card/red.png",
}
@@ -185,7 +190,7 @@ FieldDefaults.CONSTANTS = {
neighborHops = 2, -- connection hops drawn around the current map
stepFrames = 16, -- 1px per frame, 16 frames per tile
bikeStepFrames = 8, -- the bicycle doubles walking speed
turnFrames = 2, -- the extra OverworldLoop pass after a turn
turnFrames = 4, -- tap window before a turn commits to a step
},
-- cumulative slot thresholds out of 256 (engine/battle/wild_encounters.asm)
encounterBuckets = { 51, 102, 141, 166, 191, 216, 229, 242, 253, 256 },
+82 -13
View File
@@ -717,6 +717,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, {
@@ -1155,6 +1160,13 @@ function OverworldState:handleInput()
end
end
-- .noDirectionButtonsPressed (home/overworld.asm) is the only place that
-- sets wCheckFor180DegreeTurn, so reaching this line -- a poll that found
-- no direction held -- is what re-arms the next turn in place. The early
-- returns above (mid-step, A, START) skip it exactly as the original's
-- jumps to .moveAhead and .displayDialogue do (#415).
self.player.turnArmed = true
-- Cycling Road's downhill pull: with no d-pad held the bike rolls
-- south (home/overworld.asm JoypadOverworld's simulated PAD_DOWN).
-- The mask there is PAD_CTRL_PAD | PAD_B | PAD_A, so HOLDING A or B
@@ -1803,7 +1815,7 @@ function OverworldState:tryHiddenObject(fx, fy)
if h.x == fx and h.y == fy then
save.hiddenTaken = save.hiddenTaken or {}
if save.hiddenTaken[key] then return false end
if not require("src.inventory.Bag").add(save, h.item, 1) then
if not require("src.inventory.Bag").add(save, h.item, 1, Game.data) then
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
return true
end
@@ -1891,9 +1903,20 @@ function OverworldState:tryHiddenObject(fx, fy)
end
end
-- bench guys (data/events/bench_guys.asm)
-- Bench guys (data/events/bench_guys.asm). A hidden_event's fourth byte
-- is wHiddenEventFunctionArgument, not a facing gate -- pokered's own
-- macro comment says the SPRITE_FACING_* values parked there "do not
-- actually prevent the player from interacting with them in any
-- direction" (data/events/hidden_events.asm). The facing that does decide
-- a bench guy is PrintBenchGuyText's own test against BenchGuyTextPointers,
-- SPRITE_FACING_LEFT for all twelve seats, which the manifest carries as
-- `textFacing`. Gating on the hidden_event byte instead silenced the four
-- seats that store SPRITE_FACING_UP (Vermilion, Saffron, Fuchsia,
-- Cinnabar): (0,4) is the bench wall cell and can only ever be faced from
-- the right (#488).
for _, h in ipairs(extras.benchGuys[self.map.id] or {}) do
if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then
local want = h.textFacing or h.facing
if h.x == fx and h.y == fy and (not want or want == facing) then
local text = OverworldState.benchGuyText(Game.data, save, h.text)
if text then
Game.stack:push(TextBox.new(Game, text))
@@ -2423,7 +2446,7 @@ function OverworldState:talkTo(npc)
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats
-- the string "0" as truthy, so screen it out and fall through to text.
if d.item and d.item ~= "0" and d.item ~= 0 then
if not require("src.inventory.Bag").add(Game.save, d.item, 1) then
if not require("src.inventory.Bag").add(Game.save, d.item, 1, Game.data) then
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
return
end
@@ -2558,8 +2581,7 @@ function OverworldState:openPC(onDone)
table.insert(items, {
label = Strings("PROF.OAK's PC"),
onSelect = function()
self:dexRating()
done()
self:openOaksPC(done)
end,
})
end
@@ -2586,11 +2608,41 @@ function OverworldState:openPC(onDone)
noSound = true }))
end
-- The PROF. OAK's PC session (engine/menus/oaks_pc.asm OpenOaksPC): the
-- access text, "Want to get your #DEX rated?" with a YES/NO, then the
-- rating, and "Closed link to PROF.OAK's PC." before control returns -- the
-- intro and closing links the launcher skipped, jingle ordering aside (#576).
function OverworldState:openOaksPC(onDone)
local done = onDone or function() end
local text = Game.data.text or {}
local accessed = text._AccessedOaksPCText
or Strings("Accessed PROF.\nOAK's PC.\fAccessed POKéDEX\nRating System.")
local rated = text._GetDexRatedText
or Strings("Want to get your\nPOKéDEX rated?")
local closed = text._ClosedOaksPCText
or Strings("Closed link to\nPROF.OAK's PC.")
local function close()
Game.stack:push(TextBox.new(Game, closed, done))
end
Game.stack:push(TextBox.new(Game, accessed, function()
-- _GetDexRatedText ends with `done`, so the YES/NO pops as soon as the
-- text has typed out, with no button wait in between (YesNoChoice)
Game.stack:push(TextBox.new(Game, rated, nil, {
choice = function(yes)
if not yes then
close()
return
end
self:dexRating(close)
end,
}))
end))
end
-- Prof. Oak's dex rating service (engine/events/pokedex_rating.asm):
-- the completion line with seen AND owned counts, then the per-decade
-- rating text.
function OverworldState:dexRating(onDone)
require("src.core.Sound").play(Game.data, "Pokedex_Rating")
local seen, owned = 0, 0
for _ in pairs(Game.save.pokedex.seen or {}) do seen = seen + 1 end
for _ in pairs(Game.save.pokedex.owned or {}) do owned = owned + 1 end
@@ -2607,7 +2659,15 @@ function OverworldState:dexRating(onDone)
completion = completion
:gsub("{NUM:hDexRatingNumMonsSeen[^}]*}", tostring(seen))
:gsub("{NUM:hDexRatingNumMonsOwned[^}]*}", tostring(owned))
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating, onDone))
-- DisplayDexRating prints the completion line, then the tier text, and
-- only then plays the rating jingle and waits for a button -- the fanfare
-- must not pre-empt the evaluation it celebrates (#576). auto.wait hands
-- the box to the plain A/B path once the jingle has sounded.
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating, onDone, {
auto = { wait = true, sound = function()
return require("src.core.Sound").play(Game.data, "Pokedex_Rating")
end },
}))
end
-- AnimateHealingMachine (engine/overworld/healing_machine.asm): balls
@@ -3829,7 +3889,15 @@ end
-- dev-mode hot reload). The neighbors go too: their strips render the
-- same tileset. When the active map is the one that changed, the player
-- is clamped back in bounds, the NPC pool is reused so runtime handles
-- survive, and the tile-pair table is re-read.
-- survive, and the tile-pair table is re-read. keepMusic: a reload is not a
-- map entry. Its counterpart ReloadMapData (home/reload_tiles.asm) only
-- re-reads the map view and the tileset tile patterns after the Pokedex /
-- start menu / PC clobbered VRAM; map music starts from LoadMapData alone
-- (home/overworld.asm, gated on BIT_NO_MAP_MUSIC). Whatever is playing
-- belongs to the state on top, so a COLORS cycle during a battle
-- (PaletteFX.setMode reloads the live map to rebuild its baked atlas) must
-- not drop the route theme over the battle song (#484). The out-of-bounds
-- fallback below is a real map change and keeps its map music.
function OverworldState:reloadMap(mapId, reason)
MapLoader.invalidate(mapId)
for _, nb in ipairs(self.neighbors or {}) do MapLoader.invalidate(nb.map.id) end
@@ -3837,7 +3905,8 @@ function OverworldState:reloadMap(mapId, reason)
local p = self.player
local x, y, facing = p.cellX, p.cellY, p.facing
Collision.load(Game.data)
self:setMap(mapId, x, y, facing, { seamless = true, via = "reload" })
self:setMap(mapId, x, y, facing,
{ seamless = true, via = "reload", keepMusic = true })
if not self.map:inBounds(x, y) then
local heal = self:healPoint()
Logger.warn("map %s reloaded out from under the player; sending to %s",
@@ -4568,9 +4637,9 @@ function OverworldState:drawUI()
-- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm
-- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a
-- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so the
-- front pic stands in for every frame of the script; PikachuFollower
-- .picLift lifts it on the runs that draw the alternate pose, and the
-- script's base frame is ripped as pikachu/pikapic_N.png (#561) but the
-- pikaframe overlays on top of it are not, so PikachuFollower
-- .picLift lifts the base on the runs that draw the alternate pose, and the
-- script's own duration times the beat (#407, #424). Palette zone
-- PAL_PIKACHU_PORTRAIT covers (7,6)-(11,10) via sgbPalettes above.
if self.emote and self.emote.pikaPic then
+31 -8
View File
@@ -518,9 +518,12 @@ end
-- pikachu_emotions.asm): pick a scripted emotion, then play its bubble
-- and voiced PCM clip, and raise the framed Pikachu picture the original
-- puts over the map (pikaemotion_pikapic -> pikachu_pic_animation.asm
-- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. The
-- per-emotion animation frames (gfx/pikachu/unknown_*) are not extracted,
-- so the front pic stands in for all twenty of them (#407).
-- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. Each
-- script's BASE 5x5 frame is ripped as pikachu/pikapic_N.png (#561); the
-- pikaframe overlays it alternates with are a second full-body pose out of
-- the same blob and are still unported, so picLift below stands in for
-- their motion and the battle front pic covers caches built before the
-- rip (#407).
-- ---------------------------------------------------------------------
-- PikachuEmotionTable, reduced to each entry's bubble + pikaemotion_pcm
@@ -715,9 +718,17 @@ function PikachuFollower.talk(game, ow, npc, done)
-- 40x40 front pic is the size of PikaAnimTilemap_1's 5x5 base frame;
-- Sprites.path keeps a mod's replacement skin in play.
local Sprites = require("src.pokemon.Sprites")
local pic = Sprites.path(game.data, "PIKACHU", "front",
{ kind = "overworld" })
local anim = PIKAPIC[PIKAPIC_SCRIPT[emotion] or emotion] or PIKAPIC[1]
-- The chosen script's own base frame (its first pikapic_loadgfx, ripped as
-- pikachu/pikapic_N.png). Red/Blue have no such art and Yellow caches
-- built before the rip do not carry it, so both fall back to the battle
-- front pic that stood in for every script before (#561).
local script = PIKAPIC_SCRIPT[emotion] or emotion
local pic = "assets/generated/pikachu/pikapic_" .. script .. ".png"
if not require("src.render.Assets").exists(pic) then
pic = Sprites.path(game.data, "PIKACHU", "front",
{ kind = "overworld" })
end
local anim = PIKAPIC[script] or PIKAPIC[1]
local hold = anim.dur * PIKAPIC_TICK
ow.emote = {
npc = npc, frames = hold, bubble = bi or false, pikaPic = pic,
@@ -790,10 +801,22 @@ function PikachuFollower.onBillEnteredMachine(game, ow)
if not (GameVersion.isYellow() and ow.pikachuBillsScene) then return end
local npc = findFollower(ow)
if not npc then return end
-- BillsHouseScript3 (pokeyellow scripts/BillsHouse.asm:100-115). The two
-- movement tables are named the wrong way round in the cartridge source:
-- hl is seeded with ..._EnterCellSeparatorDown, then
-- and a ; cp SPRITE_FACING_DOWN (SPRITE_FACING_DOWN is 0)
-- jr nz, .applyPikachuMovement
-- keeps that seeded table when the player is NOT facing down, and only the
-- fallthrough -- facing down -- swaps in ..._EnterCellSeparatorNotDown.
-- So facing down takes the long way round the cell separator and every
-- other facing walks straight up. Following the label names instead of the
-- branch inverts the scene (#455).
local steps = ow.player.facing == "down"
and { { "up", 3 } }
or { { "up", 1 }, { "left", 1 }, { "up", 2 }, { "right", 1 } }
and { { "up", 1 }, { "left", 1 }, { "up", 2 }, { "right", 1 } }
or { { "up", 3 } }
movePikachu(ow, npc, steps, function()
-- PIKAMOVEMENT_LOOK_UP closes the detour table before the bubble
npc.facing = "up"
billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE")
end)
end
+43 -7
View File
@@ -15,11 +15,21 @@ local STEP_FRAMES = 16
-- a turn in place blocks movement for the one extra OverworldLoop pass the
-- original spends after a direction change: .handleDirectionButtonPress ends
-- `jp OverworldLoop` (home/overworld.asm), and OverworldLoop burns two
-- DelayFrame calls before the next JoypadOverworld, so the sample that can
-- commit to a step lands exactly 2 fixed steps after the turn -- the same
-- 2-frames-per-iteration cadence that makes STEP_FRAMES 16 above
-- (wWalkCounter = 8, 2px per AdvancePlayerSprite) (#415)
local TURN_FRAMES = 2
-- DelayFrame calls before the next JoypadOverworld, so the step can only
-- commit at the following poll -- the same 2-frames-per-iteration cadence
-- that makes STEP_FRAMES 16 above (wWalkCounter = 8, 2px per
-- AdvancePlayerSprite).
-- Those polls sit on a 2-frame grid, so the hardware samples a press 0 or 1
-- frames after the d-pad physically goes down and the release deadline lands
-- 2 or 3 frames after that. We sample on the frame the button goes down
-- with none of that poll latency, so a flat 2 handed every tap the tightest
-- case the original could produce; 4 covers the grid instead of
-- undercutting it (#415).
local TURN_FRAMES = 4
-- The on-screen d-pad cannot produce a 60ms tap: a finger press and release
-- run well past it even before the OS batches the touch events, so the
-- overlay gets a longer window than a physical pad (#415).
local TOUCH_TURN_FRAMES = 8
function Player.new(data, cx, cy, facing)
local self = setmetatable({}, Player)
@@ -67,6 +77,11 @@ function Player.new(data, cx, cy, facing)
self.progress = 0
self.stepFlip = false
self.turnTimer = 0
-- wCheckFor180DegreeTurn (home/overworld.asm): the original only lets a
-- turn in place happen on a poll whose previous pass found no direction
-- held. It starts armed, tryMove spends it, and OverworldState:handleInput
-- re-arms it from a standstill.
self.turnArmed = true
self.inputLocked = false
return self
end
@@ -75,14 +90,35 @@ function Player:position()
return self.cellX, self.cellY
end
-- How long a fresh turn holds the step off for; see TURN_FRAMES. The
-- overlay is detected per source rather than by whether the touch controls
-- are on screen, so a phone with a controller attached still gets the
-- physical pad's window (Input:isTouchDown, src/core/Input.lua).
function Player:turnWindow()
local frames = self.turnFrames or TURN_FRAMES
local input = require("src.core.Game").input
if input and input.isTouchDown and input:isTouchDown(self.facing) then
return math.max(frames, TOUCH_TURN_FRAMES)
end
return frames
end
-- Attempt to start a step; returns "moved"|"turned"|"blocked"|nil.
function Player:tryMove(dir, map, entities)
if self.moving or self.inputLocked then return nil end
if self.facing ~= dir then
self.facing = dir
self.turnTimer = self.turnFrames or TURN_FRAMES
self.bumpFrames = nil -- turning to a new facing ends any wall-bonk cycle
return "turned"
-- .handleDirectionButtonPress only reaches the turn while
-- wCheckFor180DegreeTurn is still set, and .noDirectionButtonsPressed is
-- the one place that sets it (home/overworld.asm), so a facing change
-- made without the d-pad ever coming up steps straight away rather than
-- paying the turn delay at every corner (#415)
if self.turnArmed then
self.turnArmed = false
self.turnTimer = self:turnWindow()
return "turned"
end
end
if self.turnTimer > 0 then return nil end
local ok, why = Collision.canMove(map, entities, self, dir)
+135
View File
@@ -0,0 +1,135 @@
-- The Cerulean badge man's list needs a CANCEL row (#569).
-- CeruleanBadgeHouseMiddleAgedManText loops DisplayListMenuID over
-- .BadgeItemList and takes `jr c, .done` out (pokered
-- scripts/CeruleanBadgeHouse.asm); PrintListMenuEntries prints
-- ListMenuCancelText at the list's $FF terminator, so CANCEL is a real row
-- and choosing it sets carry the same way B does.
-- POKEPORT_DRIVER=tests/drivers/badge_cancel_bug569_test.lua POKEPORT_IDENTITY=bug569 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local ListMenu = require("src.ui.ListMenu")
local TextBox = require("src.render.TextBox")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- pokered data/maps/objects/CeruleanBadgeHouse.asm: the middle-aged man is
-- object_event 5, 3, STAY, so he never wanders off the tile; the floor to
-- his west is the natural approach.
local MAP = "CERULEAN_BADGE_HOUSE"
local NPC = "CERULEANBADGEHOUSE_MIDDLE_AGED_MAN"
local STAND = { x = 4, y = 3, facing = "right" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- a renamed or unextracted text label reads on screen exactly like the
-- missing row did: nothing happens
local t = game.data.text
for _, key in ipairs({
"_CeruleanBadgeHouseMiddleAgedManText",
"_CeruleanBadgeHouseMiddleAgedManWhichBadgeText",
"_CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText",
"_CeruleanBadgeHouseBoulderBadgeText",
}) do
check(key .. " resolves to a string", type(t[key]) == "string")
end
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local function manIn(ow)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == NPC then return n end
end
return nil
end
local function facingTheMan()
local ow = game.overworld
local man = ow and manIn(ow)
if not man then return false end
local fx, fy = ow.player:facingCell()
return ow:npcAtCell(fx, fy) == man
end
local ow = game.overworld
local man = ow and manIn(ow)
check("the badge man is on " .. MAP, man ~= nil)
if man and not facingTheMan() then
-- a map edit moved him: take any free walkable neighbour instead.
-- {dx, dy, facing} is the offset from him to the stand cell plus the
-- direction that looks back, so +1 on x means facing left.
local sides = {
{ -1, 0, "right" }, { 1, 0, "left" }, { 0, 1, "up" }, { 0, -1, "down" },
}
for _, s in ipairs(sides) do
local cx, cy = man.cellX + s[1], man.cellY + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log("approach cell is blocked, standing on", cx, cy, "facing", s[3])
U.teleport(game, MAP, cx, cy, s[3])
U.wait(10)
break
end
end
end
check("player is standing in front of the badge man", facingTheMan())
-- talk, then mash through the greeting and the "Which badge?" prompt
U.tap(game, "a")
U.wait(20)
-- the greeting runs three pages before the "Which badge?" prompt, so give
-- the mash room; an A landing mid-type only speeds that page up
local list
for _ = 1, 60 do
local top = game.stack:top()
if getmetatable(top) == ListMenu then list = top break end
if getmetatable(top) == TextBox then U.tap(game, "a") end
U.wait(10)
end
check("talking to him opened the badge list", list ~= nil)
if list then
local labels = {}
for i, item in ipairs(list.items) do labels[i] = item.label end
U.log("badge list rows:", table.concat(labels, " "))
check("the list has the eight badges plus one more row", #list.items == 9)
local last = list.items[#list.items]
check("the last row is CANCEL", last.label == "CANCEL")
check("the CANCEL row carries no badge to describe", last.value == nil)
-- scroll it into view so the screenshot shows the row, not just the data
for _ = 1, #list.items do
if list.index == #list.items then break end
U.tap(game, "down")
U.wait(3)
end
check("cursor reached CANCEL", list.index == #list.items)
check("badge list screenshot",
U.shot(game, SHOT_DIR .. "/bug569_badge_list.png"))
U.tap(game, "a")
U.wait(25)
local top = game.stack:top()
check("CANCEL closed the list", getmetatable(top) ~= ListMenu)
check("CANCEL printed the goodbye line, like B does",
getmetatable(top) == TextBox)
check("goodbye screenshot",
U.shot(game, SHOT_DIR .. "/bug569_after_cancel.png"))
U.tap(game, "a")
U.wait(25)
end
U.log("The goodbye line has been dismissed; press A on the man to run the")
U.log("list again. CANCEL should be the ninth row under EARTHBADGE and")
U.log("should close the whole conversation with \"Visit any time\" -- the")
U.log("near miss is a CANCEL that only closes the list and drops you back")
U.log("on the \"Which badge?\" prompt. The badge rows above it are the")
U.log("control: each prints its description and returns to the list.")
U.log("Screenshots are under " .. SHOT_DIR)
while true do
coroutine.yield()
end
end
+196
View File
@@ -0,0 +1,196 @@
-- Ear check: cycling COLORS with 2 during a battle keeps the battle song (#484).
-- The COLORS change rebuilds the live map's atlas through reloadMap, which used
-- to re-enter the map and fire PlayMapMusic; ReloadMapData (home/reload_tiles.asm)
-- only re-reads the view and tile patterns, map music comes from LoadMapData
-- alone (home/overworld.asm). The invariants are in tests/engine/battle_colors_bug484.lua.
-- POKEPORT_DRIVER=tests/drivers/battle_colors_bug484_test.lua POKEPORT_IDENTITY=bug484 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 Music = require("src.core.Music")
local PaletteFX = require("src.render.PaletteFX")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
-- pokered data/maps/objects/Route1.asm puts both youngsters at (5,24) and
-- (15,13) and the sign at (9,27), so the top of the road is empty; the
-- battle is pushed straight in, the cell is only somewhere to stand.
local MAP = "ROUTE_1"
local STAND = { x = 5, y = 6, facing = "down" }
local PARTY = { { "BULBASAUR", 12 }, { "PIDGEOTTO", 18 } }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- what the ear cannot check -----------------------------------------
local opts = game.save.options or {}
local musicVol = opts.musicVol or 7
local sfxVol = opts.sfxVol or 7
if musicVol == 0 then
U.log("FAIL MUSIC volume is 0. Everything below is about which song is")
U.log(" playing, and at 0 there is no song. Set MUSIC to 7 in OPTION")
U.log(" and run this again, or the whole check is worthless.")
end
if sfxVol == 0 then
U.log("FAIL SFX volume is 0, so the battle menu's own click is gone too")
U.log(" and there is no way to tell a dead audio path from a fixed bug.")
U.log(" Set SFX to 7 in OPTION.")
end
check(("MUSIC volume %d, SFX volume %d"):format(musicVol, sfxVol),
musicVol > 0 and sfxVol > 0)
local battleSongs = (game.data.audio or {}).battle or {}
local mapSongs = (game.data.audio or {}).mapSongs or {}
check(MAP .. " has a map theme to wrongly cut in (" ..
tostring(mapSongs[MAP]) .. ")", mapSongs[MAP] ~= nil)
check("the wild battle theme exists (" .. tostring(battleSongs.wild) .. ")",
battleSongs.wild ~= nil)
check("they are different songs, so the swap would be audible",
battleSongs.wild ~= nil and battleSongs.wild ~= mapSongs[MAP])
U.log(" COLORS ladder:", table.concat(PaletteFX.MODES, ", "))
-- Every music cue in the run, forwarded so playback is untouched. A map
-- cue raised while a battle owns the screen is the bug, and it is the one
-- thing an ear can miss if the two songs happen to open alike.
local cues = {}
local realPlay, realPlayMap = Music.play, Music.playMap
Music.play = function(data, song, loop, ctx)
cues[#cues + 1] = { song = song, reason = ctx and ctx.reason or "direct" }
return realPlay(data, song, loop, ctx)
end
Music.playMap = function(data, mapId, onBike, surfing)
cues[#cues + 1] = { song = "(playMap)", reason = "map", mapId = mapId }
return realPlayMap(data, mapId, onBike, surfing)
end
local function mapCuesSince(mark)
local hits = {}
for i = mark + 1, #cues do
if cues[i].reason == "map" then hits[#hits + 1] = cues[i].song end
end
return hits
end
local function lastSong()
for i = #cues, 1, -1 do
if cues[i].song ~= "(playMap)" then return cues[i].song end
end
return nil
end
-- ---- get into a battle --------------------------------------------------
game.save.party = {}
for _, slot in ipairs(PARTY) do
table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2]))
end
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(20)
local ow = game.overworld
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
-- a map edit moved the road: any free cell on Route 1 will do, the
-- battle is pushed rather than walked into
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(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
cx, cy)
U.teleport(game, MAP, cx, cy, STAND.facing)
U.wait(20)
ow = game.overworld
break
end
end
end
check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
U.wait(60) -- let the route theme actually get going first
U.log(" route theme:", tostring(lastSong()))
local battle = BattleState.newWild(game, "RATTATA", 5)
battle.onFinish = function() end
ow:pushBattle(battle)
for _ = 1, 400 do
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
U.wait(1)
end
check("the battle reached the screen", game.stack:top() == battle)
for _ = 1, 120 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(6)
end
check("the battle reached its FIGHT/PKMN/ITEM/RUN menu", battle.phase == "menu")
local songInBattle = lastSong()
check("the battle theme is what is playing (" .. tostring(songInBattle) .. ")",
songInBattle == battleSongs.wild)
U.shot(game, DIR .. "/bug484_1_" .. tostring(PaletteFX.mode) .. ".png")
-- ---- press 2, for real --------------------------------------------------
local before = PaletteFX.mode
local mark = #cues
game:keypressed("2")
U.wait(30)
check("pressing 2 in a battle still changed COLORS ("
.. PaletteFX.modeLabel(before) .. " -> "
.. PaletteFX.modeLabel(PaletteFX.mode) .. ")",
PaletteFX.mode ~= before)
local strays = mapCuesSince(mark)
check("no map music cue came out of it (#484)", #strays == 0)
check("the battle theme is still the current song",
lastSong() == songInBattle)
U.shot(game, DIR .. "/bug484_2_" .. tostring(PaletteFX.mode) .. ".png")
-- the whole ladder, because a partial fix can leak on one mode only
local seen = { [before] = true, [PaletteFX.mode] = true }
local leaked = {}
for _ = 2, #PaletteFX.MODES do
local m = #cues
game:keypressed("2")
U.wait(20)
seen[PaletteFX.mode] = true
for _, s in ipairs(mapCuesSince(m)) do
leaked[#leaked + 1] = PaletteFX.modeLabel(PaletteFX.mode) .. " (" .. s .. ")"
end
end
local modes = 0
for _ in pairs(seen) do modes = modes + 1 end
check(("all %d COLORS modes were reachable from inside the battle"):format(modes),
modes == #PaletteFX.MODES)
check("no mode on the ladder started the route theme", #leaked == 0)
if #leaked > 0 then U.log(" leaked on:", table.concat(leaked, ", ")) end
check("still the battle theme after the full ladder",
lastSong() == songInBattle)
U.shot(game, DIR .. "/bug484_3_" .. tostring(PaletteFX.mode) .. ".png")
-- ---- over to you --------------------------------------------------------
U.log("You are at the battle menu with the wild theme running. Press 2 a few")
U.log("times: the wash behind the two pics, RATTATA's shading and the HP bars")
U.log("move mode to mode (shots 1 and 2 are one press apart), and the music")
U.log("underneath keeps going without a seam. #484 dropped Route 1's theme")
U.log("over it on the first press. The near miss to listen for is the battle")
U.log("theme restarting from its opening bar rather than being replaced, and")
U.log("the near miss to look for is nothing moving at all -- a hotkey switched")
U.log("off in battle is not a fix. A on the menu clicks, so that click is your")
U.log("proof the audio path is up at all.")
U.log("Shots: " .. DIR .. "/bug484_*.png")
-- keeps reporting after the hand-off: a map cue with a battle on top is the
-- bug whether or not the two songs are easy to tell apart by ear
local reported = #cues -- only what happens from the hand-off on
while true do
local top = game.stack:top()
local inBattle = top == battle or (top and top.battle == battle)
if inBattle and #cues > reported then
for i = reported + 1, #cues do
if cues[i].reason == "map" then
U.log("FAIL map music cue during the battle:", tostring(cues[i].song),
tostring(cues[i].mapId))
end
end
end
reported = #cues
coroutine.yield()
end
end
+186
View File
@@ -0,0 +1,186 @@
-- Look at the two boxes a test cannot judge: the bike shop's BICYCLE/CANCEL
-- price window (#568) and a Pokemon Center bench guy answering A (#488).
-- Oracle: scripts/BikeShop.asm BikeShopClerkText (TextBoxBorder 0,0 b=4 c=15
-- over BikeShopMenuText/BikeShopMenuPrice) and data/events/bench_guys.asm.
-- The logic halves are asserted in tests/parity_bike_shop_clerk_bug568.lua
-- and tests/parity_bench_guy_facing_bug488.lua.
-- POKEPORT_DRIVER=tests/drivers/bike_shop_bug568_test.lua POKEPORT_IDENTITY=bug568 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local TextBox = require("src.render.TextBox")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
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
local function boxOnScreen()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return nil end
local lines = {}
for _, page in ipairs(top.pages or {}) do
for _, line in ipairs(page) do lines[#lines + 1] = line end
end
return top, table.concat(lines, " / ")
end
-- A missing string or a renamed label goes quiet in exactly the way both
-- bugs did, so resolve everything first and say so before anyone looks.
local t = game.data.text
for _, label in ipairs({ "_BikeShopClerkWelcomeText",
"_BikeShopClerkDoYouLikeItText", "_BikeShopCantAffordText",
"_BikeShopComeAgainText", "_VermilionPokecenterGuyText" }) do
check(label .. " resolves", type(t[label]) == "string" and t[label] ~= "")
end
check("BICYCLE is an item the window can name",
game.data.items.BICYCLE ~= nil)
local seat = (game.data.field.hiddenExtras.benchGuys.VERMILION_POKECENTER
or {})[1]
check("the Vermilion bench guy is in the generated data", seat ~= nil)
check("his seat is faced from the left (BenchGuyTextPointers)",
seat and seat.textFacing == "left")
if failures > 0 then
U.log(failures, "check(s) failed above; the screens below will not tell")
U.log("you anything the log has not already said.")
end
-- ------------------------------------------------------------- #488
-- data/events/hidden_events.asm parks the seat at (0,4); it is the bench
-- wall itself, so the player stands on the floor tile east of it. The
-- cell north of the seat is walkable too and is the control: in the
-- original, PrintBenchGuyText only answers a LEFT facing, so an A press
-- from up there does nothing, and that is correct.
local BENCH = { map = "VERMILION_POKECENTER", x = seat and seat.x or 0,
y = seat and seat.y or 4 }
U.teleport(game, BENCH.map, BENCH.x, BENCH.y - 1, "down")
U.wait(10)
U.tap(game, "a")
U.wait(20)
check("facing the seat from the north stays silent, like the original",
boxOnScreen() == nil)
-- the seat's own approach, with a fallback in case a map edit walls it in
local stand = { x = BENCH.x + 1, y = BENCH.y, facing = "left" }
if not game.overworld.map:isWalkableCell(stand.x, stand.y) then
for _, side in ipairs({ { -1, 0, "right" }, { 0, 1, "up" }, { 0, -1, "down" } }) do
local cx, cy = BENCH.x + side[1], BENCH.y + side[2]
if game.overworld.map:isWalkableCell(cx, cy) then
U.log("the floor east of the seat is gone; standing at", cx, cy)
stand = { x = cx, y = cy, facing = side[3] }
break
end
end
end
U.teleport(game, BENCH.map, stand.x, stand.y, stand.facing)
U.wait(10)
U.tap(game, "a")
U.wait(90) -- let the first page finish typing before the shot
local box, text = boxOnScreen()
check("A from the east opens the bench guy's box (#488)", box ~= nil)
if box then
U.log("bench guy reads:", text)
check("it is his line about no universally strong POKeMON",
text:find("universally", 1, true) ~= nil)
check("screenshot on disk",
U.shot(game, SHOT_DIR .. "/bug488_bench_guy.png"))
U.log("captured", SHOT_DIR .. "/bug488_bench_guy.png")
U.tap(game, "a")
U.wait(20)
end
-- ------------------------------------------------------------- #568
-- pokered data/maps/objects/BikeShop.asm: the clerk stands at (6,2)
-- behind the counter row, so the player talks across it from (6,4).
-- Clear the bike and the voucher so the talk takes the sales pitch.
game.save.inventory.BICYCLE = nil
game.save.inventory.BIKE_VOUCHER = nil
game.save.flags.EVENT_GOT_BICYCLE = nil
U.teleport(game, "BIKE_SHOP", 6, 4, "up")
U.wait(10)
local ow = game.overworld
local clerk
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == "BIKESHOP_CLERK" then clerk = n end
-- the middle-aged woman paces; park her so she cannot wander into shot
if n.def and n.def.name == "BIKESHOP_MIDDLE_AGED_WOMAN" then
n.frozen = true
end
end
check("the clerk is on the map", clerk ~= nil)
-- talking across a counter reads one cell further, so the stand cell is
-- two south of him; fall back to any free neighbour if the shop is redrawn
local function facingTheClerk()
local w = game.overworld
local fx, fy = w.player:facingCell()
if w:npcAtCell(fx, fy) == clerk then return true end
if not w.map:isCounterCell(fx, fy) then return false end
local Collision = require("src.world.Collision")
local bx, by = Collision.target(fx, fy, w.player.facing)
return w:npcAtCell(bx, by) == clerk
end
if clerk and not facingTheClerk() then
for _, side in ipairs({ { 0, 2, "up" }, { 0, 1, "up" }, { 0, -1, "down" },
{ 1, 0, "left" }, { -1, 0, "right" } }) do
local cx, cy = clerk.cellX + side[1], clerk.cellY + side[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log("(6,4) no longer reaches the counter; standing at", cx, cy)
U.teleport(game, "BIKE_SHOP", cx, cy, side[3])
U.wait(10)
break
end
end
end
check("the player is at the counter", facingTheClerk())
U.tap(game, "a")
U.wait(20)
local _, welcome = boxOnScreen()
check("A opens the welcome line", welcome ~= nil)
if welcome then U.log("clerk reads:", welcome) end
-- mash through the welcome box; the pitch box then types itself out and
-- pops on its own, handing the screen to the price window
local window
for _ = 1, 200 do
local top = game.stack:top()
if top ~= game.overworld and getmetatable(top) ~= TextBox then
window = top
break
end
U.tap(game, "a")
U.wait(4)
end
check("the price window is up and holding input (#568)", window ~= nil)
if window then
check("the cursor starts on BICYCLE", window.index == 1)
check("it kept the pitch line for the bottom box",
type(window.footer) == "table" and #window.footer > 0)
check("screenshot on disk",
U.shot(game, SHOT_DIR .. "/bug568_price_window.png"))
U.log("captured", SHOT_DIR .. "/bug568_price_window.png")
end
U.log("The window on screen is the shop menu: BICYCLE over CANCEL at the")
U.log("top left, \194\1651000000 beside the BICYCLE row, cursor on BICYCLE, and")
U.log("\"It's a cool BIKE! Do you want it?\" still in the bottom box under it.")
U.log("Input is live. Up and down move between the two rows and clamp -- the")
U.log("cursor must not wrap. A buys, B or CANCEL backs out; either way the")
U.log("window has to stay on screen behind the closing lines and only")
U.log("disappear with \"Come back again some time!\". A window that vanishes")
U.log("the moment you answer, or a bottom box that blanks while the menu is")
U.log("up, is the near miss to watch for. Talk to him again to replay it.")
U.log("The boy by the door is the control: he has no menu, just a line.")
while true do
coroutine.yield()
end
end
+216
View File
@@ -0,0 +1,216 @@
-- Manual check that the rival shoves Red off the EEVEE ball on the right
-- beat (#559). pokeyellow scripts/OaksLab.asm: OaksLabChoseStarterScript
-- queues DOWN + RIGHT x3, and .asm_1c564 polls until wNPCNumScriptedSteps
-- reads 1 before it simulates the PAD_RIGHT x2 -- so the push starts as the
-- rival steps onto the player's own tile, not when he leaves the table.
-- No POKEPORT_SPEED: the whole point is which frame the player starts on.
-- No POKEPORT_IDENTITY either -- a sandbox save dir has no Yellow cache and
-- boots stale Red data underneath a Yellow-looking run.
-- POKEPORT_DRIVER=tests/drivers/eevee_shove_bug559_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local GameVersion = require("src.core.GameVersion")
local MapScripts = require("src.script.MapScripts")
local Commands = require("src.script.Commands")
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 load data/scripts/oaks_lab.lua instead (three starter
-- balls, no shove at all), so every line below would fail for the wrong
-- reason on the Red cache.
if not check("running the Yellow cache (POKEPORT_VERSION=yellow)",
GameVersion.isYellow()) then
U.log("Yellow is the only version with an EEVEE ball. Re-run with")
U.log("POKEPORT_VERSION=yellow.")
idle()
end
-- pokeyellow data/maps/objects/OaksLab.asm: OAKSLAB_RIVAL (4,3),
-- OAKSLAB_EEVEE_POKE_BALL (7,3), OAKSLAB_OAK1 (5,2). The player talks to
-- the ball from (7,4), and that y == 4 is what selects the shove branch
-- in both the original (.asm_1c564 tests wYCoord) and the port.
local MAP = "OAKS_LAB"
local BALL = "OAKSLAB_EEVEE_POKE_BALL"
local RIVAL_INDEX = 1
local RIVAL_HOME = { x = 4, y = 3 }
local STAND = { x = 7, y = 4, facing = "up" }
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
-- data/scripts/init.lua picks oaks_lab_yellow over oaks_lab on the Yellow
-- cache; if the Red module won, the ball is one of three starters and
-- nothing below means anything. Asked after the teleport because the
-- registry only fills when OverworldState first requires it.
local labModule = require("data.scripts.oaks_lab_yellow")
local bound = MapScripts.talkScript(MAP, "TEXT_OAKSLAB_EEVEE_POKE_BALL")
U.log("the map dispatches a", type(bound), "for the ball")
check("the Yellow ball handler is the one bound to the map",
bound ~= nil and bound == labModule.talk.TEXT_OAKSLAB_EEVEE_POKE_BALL)
local ow = game.overworld
local ctx = { game = game, save = game.save, overworld = ow }
local function npcNamed(name)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == name then return n end
end
return nil
end
-- The scene the shove belongs to: Oak has asked the player to choose and
-- the rival is still standing at the table. Oak himself starts hidden on
-- this map, so put him back the way OaksLabScript does before its speech.
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_OAK_ASKED_TO_CHOOSE_MON = true
game.save.flags.EVENT_GOT_STARTER = nil
game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
Commands.show_object(ctx, MAP, "OAKSLAB_RIVAL")
Commands.show_object(ctx, MAP, "OAKSLAB_OAK1")
U.wait(2)
local rival = ow:npcByIndex(RIVAL_INDEX)
if rival and (rival.cellX ~= RIVAL_HOME.x or rival.cellY ~= RIVAL_HOME.y) then
-- he wanders nowhere on this map (STAY), but a converted save can drop
-- him mid-walk; pin him back on the table corner he starts from
U.log("rival was at", rival.cellX, rival.cellY, "- pinning him to",
RIVAL_HOME.x, RIVAL_HOME.y)
Commands.place_npc(ctx, RIVAL_INDEX, RIVAL_HOME.x, RIVAL_HOME.y, "down")
end
if not check("the rival is on the map at (4,3)",
rival ~= nil and rival.cellX == RIVAL_HOME.x
and rival.cellY == RIVAL_HOME.y) then
idle()
end
local ball = npcNamed(BALL)
if not check("the EEVEE ball object is on the map", ball ~= nil) then
U.log("Nothing to talk to. A save past EVENT_GOT_STARTER has already")
U.log("hidden it, and this scene cannot be replayed on that save.")
idle()
end
-- derive the talking cell from the ball rather than trusting (7,4): a
-- later map edit degrades to another free neighbour instead of parking
-- the player inside the table
local DIRS = { { "up", 0, 1 }, { "down", 0, -1 },
{ "left", 1, 0 }, { "right", -1, 0 } }
local function facingBall()
local fx, fy = ow.player:facingCell()
return ow:npcAtCell(fx, fy) == ball
end
if not facingBall() then
for _, d in ipairs(DIRS) do
local cx, cy = ball.cellX + d[2], ball.cellY + d[3]
if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy)
and not ow:npcAtCell(cx, cy) then
U.log("standing at", cx, cy, "facing", d[1], "instead")
U.teleport(game, MAP, cx, cy, d[1])
U.wait(6)
ow = game.overworld
ctx.overworld = ow
ball = npcNamed(BALL)
rival = ow:npcByIndex(RIVAL_INDEX)
break
end
end
end
check("player is facing the ball", facingBall())
local p = ow.player
U.log("player at", p.cellX, p.cellY, "facing", p.facing,
"| rival at", rival.cellX, rival.cellY,
"| ball at", ball.cellX, ball.cellY)
-- y == 4 is the branch under test; from anywhere else both the original
-- and the port just walk the rival over with no shove at all
check("the player is on the row that triggers the shove (y == 4)",
p.cellY == 4)
if U.shot(game, SHOT_DIR .. "/bug559_before.png") then
U.log("captured", SHOT_DIR .. "/bug559_before.png")
end
-- ---- talk to the ball and watch the two walks frame by frame ----
U.tap(game, "a")
local shoveFrame, rivalAtShove
local trace, lastCell = {}, nil
for f = 1, 900 do
local cell = rival.cellX .. "," .. rival.cellY
if cell ~= lastCell then
lastCell = cell
trace[#trace + 1] = "f" .. f .. " rival " .. cell
end
if not shoveFrame and ow.player.moving then
shoveFrame = f
rivalAtShove = { x = rival.cellX, y = rival.cellY }
end
if shoveFrame and not ow.player.moving and ow.player.cellX >= STAND.x + 2 then
break
end
U.wait(1)
end
U.log("rival trace:", table.concat(trace, " | "))
if not check("the player was pushed at all", shoveFrame ~= nil) then
U.log("The rival's walk ran but nothing moved the player, so the scene")
U.log("never reached move_player. Check the runner did not stall on the")
U.log("emote row.")
idle()
end
U.log("the push started on driver frame", shoveFrame, "with the rival at",
rivalAtShove.x, rivalAtShove.y)
-- The whole of #559: at the frame the player starts moving the rival must
-- already be alongside him on row 4, one step from the tile he is being
-- pushed off. Before the fix both walks started together and the rival
-- was still up at the table (4,3)/(4,4).
check("the rival had reached row 4 before the push", rivalAtShove.y == 4)
check("he was one step from the player's tile, not still at the table",
rivalAtShove.x >= ball.cellX - 1)
if U.shot(game, SHOT_DIR .. "/bug559_shove.png") then
U.log("captured", SHOT_DIR .. "/bug559_shove.png")
end
-- let the two walks settle, then the beat the script waits before it turns
-- the rival up to face the player (the row after move_player)
for _ = 1, 240 do
if not ow.player.moving and not rival.moving and rival.facing == "up" then
break
end
U.wait(1)
end
U.log("after the push: player at", ow.player.cellX, ow.player.cellY,
"| rival at", rival.cellX, rival.cellY, "facing", rival.facing)
check("the player ended two tiles right of where he stood",
ow.player.cellX == STAND.x + 2 and ow.player.cellY == STAND.y)
check("the rival ended on the ball's row-4 tile facing up",
rival.cellX == ball.cellX and rival.cellY == 4
and rival.facing == "up")
if U.shot(game, SHOT_DIR .. "/bug559_after.png") then
U.log("captured", SHOT_DIR .. "/bug559_after.png")
end
U.log("The shove has already happened; the box on screen is the rival")
U.log("gloating. Press A through his five lines to see the rest. Watch the")
U.log("shot pair: the rival walks down and across the table first, and Red")
U.log("only slides right as the rival arrives on top of him, both moving")
U.log("together for that last tile. The near miss to look for is Red")
U.log("stepping away early, with the rival still up at (4,3) or halfway")
U.log("along row 4 behind him -- that is #559 unchanged.")
U.log("Control case: on a save where Oak has not asked you to choose yet,")
U.log("the same A press only prints \"That's a POKe BALL\" and nobody")
U.log("moves, which is the handler working correctly.")
idle()
end
+175
View File
@@ -0,0 +1,175 @@
-- Manual check of the Silph Scope unveil in the POKEMON_TOWER_6F battle (#492).
-- PrintBeginningBattleText .isMarowak (engine/battle/common_text.asm:49-60)
-- enters disguised even with the scope and plays UnveiledGhostText + MarowakAnim
-- over the GHOST before "Wild MAROWAK appeared!"; the port used to open the
-- battle with MAROWAK already loaded. Sequencing half:
-- tests/engine/ghost_unveil_bug492.lua. Never under POKEPORT_SPEED -- the
-- unveil is the thing being timed.
-- POKEPORT_DRIVER=tests/drivers/ghost_unveil_bug492_test.lua POKEPORT_IDENTITY=bug492 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local pass, fail = 0, 0
local function check(label, ok)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- machine checks ----------------------------------------------------
-- A missing text key, a renamed asset and an unarmed branch all look like
-- the bug from outside: the battle just opens with MAROWAK on screen.
U.log("#492 Silph Scope unveil: machine checks")
check("SILPH_SCOPE is a real item", game.data.items.SILPH_SCOPE ~= nil)
check("MAROWAK is a real species", game.data.pokemon.MAROWAK ~= nil)
check("BattleState:makeUnveiledGhost exists",
type(BattleState.makeUnveiledGhost) == "function")
local unveil = game.data.text._UnveiledGhostText
check("_UnveiledGhostText extracted", type(unveil) == "string" and unveil ~= "")
if type(unveil) == "string" then
U.log("the unveil line reads:", (unveil:gsub("[\n\011\012]", " / ")))
end
check("_PokemonTower6FBeGoneText extracted",
type(game.data.text._PokemonTower6FBeGoneText) == "string")
-- the disguise pic resolves through the same override chain the battle uses
local Assets = require("src.render.Assets")
local GHOST_PIC = "assets/generated/battle/front/ghost.png"
check("the ghost front pic is in the cache",
Assets.exists(Assets.resolve(GHOST_PIC)))
local opts = game.save.options or {}
U.log("sfxVol", tostring(opts.sfxVol), "musicVol", tostring(opts.musicVol))
if opts.sfxVol == 0 or opts.musicVol == 0 then
U.log("a volume is at 0: raise it in OPTION before judging what you hear")
end
U.log("no intro cry here is CORRECT -- .isMarowak never reaches PlayCry.")
U.log("The CHANNELER at (12, 10) is the control: her GASTLY cries normally.")
-- ---- reach the trigger -------------------------------------------------
-- pokered scripts/PokemonTower6F.asm PokemonTower6FMarowakCoords:
-- dbmapcoord 10, 16. Row 17 is solid wall and (9, 16) is the stairwell
-- warp down to 5F, so the approach is from (10, 15) facing down.
local MAP = "POKEMON_TOWER_6F"
local TRIGGER = { x = 10, y = 16 }
local STAND = { x = 10, y = 15, facing = "down", step = "down" }
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
game.save.player.name = "RED"
game.save.inventory.SILPH_SCOPE = 1
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = nil
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local ow = game.overworld
check("the overworld is up on " .. MAP, ow ~= nil and ow.map ~= nil)
if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
-- a map edit moved the approach: take any walkable neighbour of the
-- trigger that is not the stairwell warp, and step back toward it.
-- {dx, dy, facing} is the offset from the trigger to the stand cell plus
-- the direction that walks back onto it.
local sides = { { 0, -1, "down" }, { 1, 0, "left" },
{ -1, 0, "right" }, { 0, 1, "up" } }
for _, s in ipairs(sides) do
local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow.map:warpAtCell(cx, cy) then
U.log(("(%d, %d) is blocked, approaching from"):format(STAND.x, STAND.y),
cx, cy, "stepping", s[3])
STAND = { x = cx, y = cy, facing = s[3], step = s[3] }
U.teleport(game, MAP, cx, cy, s[3])
U.wait(10)
ow = game.overworld
break
end
end
end
-- walk onto the trigger; MapScripts onStep fires on the completed step
U.hold(game, STAND.step, 20)
U.wait(20)
check("the Be-gone text opened", game.stack:top() ~= game.overworld)
-- ---- into the battle ---------------------------------------------------
local battle
for _ = 1, 300 do
local top = game.stack:top()
if getmetatable(top) == BattleState then battle = top break end
U.tap(game, "a")
U.wait(4)
end
check("the MAROWAK battle opened", battle ~= nil)
if not battle then
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
while true do coroutine.yield() end
end
battle.onFinish = battle.onFinish or function() end
check("the scope armed the unveil, not a plain wild battle",
battle.scopeReveal == true)
check("IsGhostBattle stays false with the scope in the bag", not battle.ghost)
check("the battle OPENED as the GHOST, not as MAROWAK (#492)",
battle.enemy and battle.enemy.name == "GHOST")
check("the real nick is held back for the unveil",
battle.ghostReal ~= nil and battle.ghostReal.name == "MAROWAK")
-- ---- watch the unveil --------------------------------------------------
-- A turns the "GHOST appeared!" and unveil boxes; stop pressing the moment
-- MarowakAnim arms so nothing mashes past "Wild MAROWAK appeared!".
for _ = 1, 600 do
if battle.ghostReveal then break end
U.tap(game, "a")
U.wait(4)
end
check("MarowakAnim started", battle.ghostReveal ~= nil)
local FLASH = BattleState.GHOST_FLASH_FRAMES
local FADE_OUT = BattleState.GHOST_FADE_OUT_FRAMES
local shotFlash, swapT, nameDuringFlash = false, nil, true
for _ = 1, 400 do
local gr = battle.ghostReveal
local t = gr and gr.t or nil
if t and t <= FLASH + FADE_OUT and battle.enemy.name ~= "GHOST" then
nameDuringFlash = false
end
if t and not swapT and battle.enemy.name ~= "GHOST" then swapT = t end
if t and t >= 25 and not shotFlash then
shotFlash = U.shot(game, DIR .. "/bug492_ghost_flashing.png")
end
if not gr and swapT then break end
U.wait(1)
end
check("the GHOST was still on screen through the flash and fade-out (#492)",
nameDuringFlash)
check(("the swap landed after the fade-out (frame %s of %d)")
:format(tostring(swapT), FLASH + FADE_OUT),
swapT ~= nil and swapT > FLASH + FADE_OUT)
check("the foe is MAROWAK once the animation finishes",
battle.enemy.name == "MAROWAK")
check("captured the flashing ghost", shotFlash)
-- the "Wild MAROWAK appeared!" box that closes .isMarowak
U.wait(30)
check("captured the unveiled MAROWAK",
U.shot(game, DIR .. "/bug492_marowak_revealed.png"))
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
-- ---- hand off ----------------------------------------------------------
U.log("The pad is yours in the battle the unveil just finished. What you")
U.log("should have seen: the box says the GHOST appeared, the SILPH SCOPE")
U.log("line follows, and only THEN does the ghost flash, dissolve and come")
U.log("back as MAROWAK before \"Wild MAROWAK appeared!\". The near miss to")
U.log("watch for is the sprite swapping early -- MAROWAK already standing")
U.log("there while the unveil text is still typing, which is #492 itself.")
U.log("To see it again: run from the battle (that steps you one cell right),")
U.log("walk back to (10, 15) and step down onto (10, 16). Beating it sets")
U.log("EVENT_BEAT_GHOST_MAROWAK and the trigger is spent for this save.")
U.log("Screenshots: " .. DIR .. "/bug492_*.png")
while true do
coroutine.yield()
end
end
+234
View File
@@ -0,0 +1,234 @@
-- Ear check: the menu click on backing out of POKéDEX / POKéMON / ITEM /
-- OPTION, on the first A on an item in ITEM, and on A over CANCEL in OPTION
-- (#570). HandleMenuInput_ (home/window.asm) replays SFX_PRESS_AB for the
-- PAD_A | PAD_B half of a menu's watched keys; DisplayOptionMenu beeps only
-- at .exitMenu (engine/menus/main_menu.asm). Silence where the original is
-- silent counts too, so this walks the mute cases as well. Invariants are in
-- tests/engine/menu_click_bug570.lua.
-- POKEPORT_DRIVER=tests/drivers/menu_click_bug570_test.lua POKEPORT_IDENTITY=bug570 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 exact ordering of
-- press and click that this run exists to judge.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Bag = require("src.inventory.Bag")
local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local OptionsMenu = require("src.ui.OptionsMenu")
local PartyMenu = require("src.ui.PartyMenu")
local Pokemon = require("src.pokemon.Pokemon")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
-- pokered data/maps/objects/PalletTown.asm: OAK at (8,5), the girl at (3,8),
-- the fisher at (11,14), warps at (5,5), (13,5) and (12,11). (10,7) is clear
-- of all of them and off every mat; the menus are what is being judged, the
-- cell only has to be somewhere the start menu opens.
local MAP = "PALLET_TOWN"
local STAND = { x = 10, y = 7, facing = "down" }
local PARTY = { { "BULBASAUR", 12 }, { "PIDGEY", 9 } }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- what the ear cannot check -----------------------------------------
local opts = game.save.options or {}
local musicVol = opts.musicVol or 7
local sfxVol = opts.sfxVol or 7
if sfxVol == 0 then
U.log("FAIL SFX volume is 0. Every claim below is about a click that is or")
U.log(" is not there, and at 0 none of them are. Set SFX to 7 in OPTION")
U.log(" and run this again, or the whole check is worthless.")
end
if musicVol == 0 then
U.log("FAIL MUSIC volume is 0, so Pallet Town's theme is gone and there is")
U.log(" nothing to tell a dead audio device from a missing click.")
U.log(" Set MUSIC to 7 in OPTION.")
end
check(("SFX volume %d, MUSIC volume %d"):format(sfxVol, musicVol),
sfxVol > 0 and musicVol > 0)
-- an unresolved sfx key is silent in exactly the way the bug was
local sfx = (game.data.audio or {}).sfx or {}
for _, key in ipairs({ "Press_AB", "Start_Menu", "Swap" }) do
check("sfx " .. key .. " is in the cache", sfx[key] ~= nil)
end
-- every cue the run makes, forwarded so playback is untouched
local cues = {}
local realPlay = Sound.play
Sound.play = function(data, name)
cues[#cues + 1] = name
return realPlay(data, name)
end
local function since(mark)
local clicks, others = 0, {}
for i = mark + 1, #cues do
if cues[i] == "Press_AB" then clicks = clicks + 1
else others[#others + 1] = cues[i] end
end
return clicks, others
end
-- one press, then long enough for the click to be its own sound
local function press(btn, want, label)
local mark = #cues
U.tap(game, btn)
U.wait(28)
local clicks, others = since(mark)
local note = ""
if #others > 0 then note = " (also: " .. table.concat(others, ", ") .. ")" end
check(("%s: %s gave %d click%s, expected %d%s")
:format(label, btn:upper(), clicks, clicks == 1 and "" or "s",
want, note),
clicks == want)
return clicks
end
local function top() return game.stack:top() end
local function isA(class) return getmetatable(top()) == class end
-- ---- set the save up so every start-menu row is live --------------------
game.save.party = {}
for _, slot in ipairs(PARTY) do
table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2]))
end
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_POKEDEX = true -- StartMenu hides POKéDEX without it
game.save.pokedex = game.save.pokedex or { seen = {}, owned = {} }
for _, id in ipairs({ "BULBASAUR", "PIDGEY", "RATTATA" }) do
game.save.pokedex.seen[id] = true
game.save.pokedex.owned[id] = true
end
Bag.add(game.save, "POTION", 3)
Bag.add(game.save, "ANTIDOTE", 2)
game.save.startMenuIndex = 1
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(20)
local ow = game.overworld
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
-- a map edit blocked the cell: any free walkable 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.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
cx, cy)
U.teleport(game, MAP, cx, cy, STAND.facing)
U.wait(20)
ow = game.overworld
break
end
end
end
check("standing on " .. MAP, ow ~= nil and ow.map.id == MAP)
-- the girl and the fisher both WALK: pin them so the map under the menus
-- looks the same on every run and in every screenshot
for _, n in ipairs((ow or {}).npcs or {}) do n.frozen = true end
-- ---- walk the menus -----------------------------------------------------
press("start", 0, "opening the start menu is silent (its own Start_Menu "
.. "cue is not the A/B click)")
check("the start menu is up", isA(Menu))
-- rows come and go with save state and the ui.start_menu.items hook, so
-- find each one by label rather than counting cursor steps
local function rowIndex(label)
for i, item in ipairs(top().items or {}) do
if item.label == label then return i end
end
return nil
end
local function openRow(label)
local menu = top()
local i = rowIndex(label)
if not i then
check("the start menu has a " .. label .. " row", false)
return false
end
menu.index = i
menu:clampScroll()
press("a", 1, "A on " .. label .. " clicks")
return true
end
if openRow(Strings("POKéDEX")) then
check("the POKéDEX list opened", isA(ListMenu))
U.shot(game, DIR .. "/bug570_1_pokedex.png")
press("b", 1, "backing out of POKéDEX clicks (#570)")
check("and the start menu came back", isA(Menu))
end
if openRow(Strings("POKéMON")) then
check("the party menu opened", getmetatable(top()) == PartyMenu)
press("b", 1, "backing out of POKéMON clicks (#570)")
check("and the start menu came back", isA(Menu))
end
if openRow(Strings("ITEM")) then
check("the bag opened", isA(ListMenu))
check("the bag has items to click on", #(top().items or {}) > 0)
press("a", 1, "the first A on an item in ITEM clicks (#570)")
check("the USE / TOSS submenu opened", isA(Menu))
U.shot(game, DIR .. "/bug570_2_bag_usetoss.png")
press("b", 1, "backing out of USE / TOSS clicks")
check("back on the item list", isA(ListMenu))
-- SELECT is inside DisplayListMenuID's watched mask but outside its
-- PAD_A | PAD_B sound test (home/list_menu.asm), so arming a swap is
-- silent and completing it makes the swap chirp, not the menu click
press("select", 0, "SELECT arms a swap without clicking")
press("select", 0, "and completing it chirps instead of clicking")
press("b", 1, "backing out of ITEM clicks (#570)")
check("and the start menu came back", isA(Menu))
end
if openRow(Strings("OPTION")) then
check("the options screen opened", getmetatable(top()) == OptionsMenu)
press("right", 0, "the Left/Right toggles are silent")
press("left", 0, "in both directions")
-- up from the first row wraps onto CANCEL, which sits one past the rows
press("up", 0, "moving the cursor is silent")
check("the cursor is on CANCEL",
top().index == #top().rows + 1)
U.shot(game, DIR .. "/bug570_3_option_cancel.png")
press("a", 1, "A on CANCEL in OPTION clicks (#570)")
check("and the start menu came back", isA(Menu))
end
if openRow(Strings("OPTION")) then
press("b", 1, "B out of OPTION clicks too (#570)")
check("and the start menu came back", isA(Menu))
end
press("start", 0, "START closes the start menu silently: draw_start_menu's "
.. "mask watches it, but it is not in the PAD_A | PAD_B sound branch")
check("back on the map", top() == game.overworld)
-- ---- over to you --------------------------------------------------------
U.log("The run above already pressed all of it; do it again by ear. Open START,")
U.log("go into POKéDEX, POKéMON, ITEM and OPTION and back out of each with B.")
U.log("Every one of those B presses makes the same short click that A on the")
U.log("row made going in, the first A on an item in ITEM makes it, and A on")
U.log("CANCEL in OPTION closes it with it. #570 was silence on all of those.")
U.log("The near miss is a click at the wrong moment: OPTION's Left/Right")
U.log("toggles and its A on a setting row stay silent, START opens and closes")
U.log("the start menu with its own longer cue and no click, and SELECT in the")
U.log("bag chirps a swap rather than clicking.")
U.log("Shots: " .. DIR .. "/bug570_*.png")
-- keeps printing the cue stream after the hand-off, so a click that lands a
-- beat late reads as a late line rather than as the right sound
local reported = #cues
while true do
if #cues > reported then
for i = reported + 1, #cues do
U.log("cue", cues[i], "frame", U.frame())
end
reported = #cues
end
coroutine.yield()
end
end
+211
View File
@@ -0,0 +1,211 @@
-- Look at the boxed menus whose rows moved down one tile (#564 start menu,
-- #572 the PC and the Pokédex entry window) and watch QUIT close the whole
-- Pokédex (#571). Row geometry comes from pokered draw_start_menu.asm
-- (hlcoord 12,2 inside a TextBoxBorder at 10,0) and players_pc.asm
-- (hlcoord 2,2 inside a 16x10 box); the exits come from pokedex.asm
-- HandlePokedexSideMenu / .exitPokedex. Screenshots land in $SHOT_DIR.
-- POKEPORT_DRIVER=tests/drivers/menu_windows_bug564_test.lua POKEPORT_IDENTITY=bug564 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Bag = require("src.inventory.Bag")
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
-- The bedroom PC is hidden_event 0, 1, OpenRedsPC, SPRITE_FACING_UP
-- (pokered data/events/hidden_objects.asm), so the only approach is the
-- floor tile below it.
local MAP = "REDS_HOUSE_2F"
local STAND = { x = 0, y = 2, facing = "up" }
local DEX_SPECIES = "BULBASAUR" -- dex #1, so it is the list's first row
local BAG_ITEM = "POTION"
-- a save the start menu will draw all seven rows for: dex flag on,
-- something owned so the dex list has a real entry, a party for LINK,
-- and one bag item so ITEM opens onto a row that has a USE/TOSS box
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_POKEDEX = true
game.save.pokedex = game.save.pokedex or { seen = {}, owned = {} }
game.save.pokedex.seen[DEX_SPECIES] = true
game.save.pokedex.owned[DEX_SPECIES] = true
game.save.party = {
Pokemon.new(game.data, DEX_SPECIES, 12),
Pokemon.new(game.data, "PIKACHU", 10),
}
Bag.add(game.save, BAG_ITEM, 3)
check("dex entry for " .. DEX_SPECIES .. " exists in the loaded data",
game.data.pokemon[DEX_SPECIES] ~= nil)
check(BAG_ITEM .. " resolves as an item",
game.data.items[BAG_ITEM] ~= nil)
-- pcTiles is what OverworldController checks before it opens the PC; an
-- empty list here and a mis-aimed player look identical from outside
local extras = game.data.field and game.data.field.hiddenExtras
local pcTile = extras and (extras.pcTiles[MAP] or {})[1]
check("the bedroom PC tile is in the field data", pcTile ~= nil)
if pcTile then
U.log("PC tile at", pcTile.x, pcTile.y, "facing", pcTile.facing)
-- a map or field-data edit moves the tile: stand under it wherever it is
STAND = { x = pcTile.x, y = pcTile.y + 1, facing = "up" }
end
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
-- fall back to any free walkable neighbour that still looks at the tile
local sides = {
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
for _, s in ipairs(sides) do
local cx, cy = pcTile.x + s[1], pcTile.y + s[2]
if ow.map:isWalkableCell(cx, cy) then
U.log("standing on", cx, cy, "facing", s[3], "instead")
STAND = { x = cx, y = cy, facing = s[3] }
U.teleport(game, MAP, cx, cy, s[3])
U.wait(10)
break
end
end
end
check("player is standing at the PC",
game.overworld ~= nil
and game.overworld.player.cellX == STAND.x
and game.overworld.player.cellY == STAND.y)
-- walk a Menu's cursor onto a row by label, with real Down presses
local function moveTo(menu, label)
for _ = 1, #menu.items + 1 do
if menu.items[menu.index] and menu.items[menu.index].label == label then
return true
end
U.tap(game, "down")
U.wait(2)
end
return false
end
-- --------------------------------------------------------------- #564
U.tap(game, "start")
U.wait(20)
local start = game.stack:top()
check("START opened the start menu", start ~= nil and start.items ~= nil)
if start and start.items then
local labels = {}
for i, it in ipairs(start.items) do labels[i] = it.label end
U.log("start menu rows:", table.concat(labels, " "))
end
check("start menu screenshot",
U.shot(game, SHOT_DIR .. "/bug564_start_menu.png"))
-- --------------------------------------------------------------- #571
check("POKéDEX is on the start menu", moveTo(start, "POKéDEX"))
U.tap(game, "a")
U.wait(20)
local dex = game.stack:top()
check("the dex list opened", dex ~= nil and dex.screenId == "PokedexMenu")
U.tap(game, "a")
U.wait(20)
local side = game.stack:top()
local isSide = side ~= nil and side ~= dex and side.items ~= nil
check("A on " .. DEX_SPECIES .. " opened the DATA/CRY/AREA/QUIT window",
isSide)
check("Pokédex entry window screenshot",
U.shot(game, SHOT_DIR .. "/bug572_pokedex_entry_menu.png"))
if isSide then
check("QUIT is on the entry window", moveTo(side, "QUIT"))
U.tap(game, "a")
U.wait(30)
local top = game.stack:top()
-- the machine-checkable half of #571: the stack has to unwind past the
-- dex list, not just pop the side menu
check("QUIT left the Pokédex entirely", top ~= dex and top ~= side)
check("QUIT landed on the start menu",
top ~= nil and top.screenId == "StartMenu")
if top and top.items and top.items[top.index] then
U.log("start menu cursor is on:", top.items[top.index].label)
check("the cursor is still on POKéDEX",
top.items[top.index].label == "POKéDEX")
end
check("start menu after QUIT screenshot",
U.shot(game, SHOT_DIR .. "/bug571_after_quit.png"))
end
-- --------------------------------------------------------------- #572
-- the bag's USE/TOSS box, reached from the same start menu
local menu = game.stack:top()
if menu and menu.items and moveTo(menu, "ITEM") then
U.tap(game, "a")
U.wait(20)
U.tap(game, "a") -- A on the first bag row opens USE/TOSS
U.wait(20)
local useToss = game.stack:top()
local ok = useToss and useToss.items and #useToss.items == 2
and useToss.items[1].label == "USE"
check("the bag's USE/TOSS box opened", ok and true or false)
check("USE/TOSS screenshot",
U.shot(game, SHOT_DIR .. "/bug572_bag_use_toss.png"))
if ok then
-- pokered data/text_boxes.asm USE_TOSS_MENU_TEMPLATE is (13,10) to
-- (19,14) with its text at 15,11: two rows exactly fill 11 and 13,
-- with no blank row under the top border. Mirror Menu:draw's own
-- bottom anchor rather than re-deriving one here -- an assertion that
-- carries its own copy of the formula passes on whatever it assumes
-- instead of on what the menu actually draws (#564, #572).
local n = #useToss.items
local bottom = useToss.ty + useToss.th - 2
local firstRow = bottom - (n - 1) * useToss.rowStep
local lastRow = bottom
check("TOSS is inside the box, not on its bottom border",
lastRow <= useToss.ty + useToss.th - 2)
check("USE and TOSS land on the pokered rows (11 and 13)",
firstRow == 11 and lastRow == 13)
U.log("USE/TOSS box ty=" .. useToss.ty .. " th=" .. useToss.th
.. ", rows land on " .. firstRow
.. " and " .. lastRow
.. ", bottom interior row is " .. bottom)
end
U.tap(game, "b")
U.wait(10)
U.tap(game, "b")
U.wait(20)
end
-- back out to the overworld and open the bedroom PC
for _ = 1, 4 do
if game.stack:top() == game.overworld then break end
U.tap(game, "b")
U.wait(15)
end
check("back in the overworld", game.stack:top() == game.overworld)
U.tap(game, "a")
U.wait(25)
local pc = game.stack:top()
local isPC = pc ~= nil and pc.screenId == "PlayerPC"
check("the bedroom PC opened", isPC)
check("player's PC screenshot",
U.shot(game, SHOT_DIR .. "/bug572_player_pc.png"))
U.log("The player's PC is open on screen; B closes it and START reopens")
U.log("the start menu. In all of these the last choice should sit on the")
U.log("row just above the bottom border and the blank row should be under")
U.log("the top border, with the ▶ level with its label -- the near miss is")
U.log("text that clears the top edge but leaves a gap at the bottom, or a")
U.log("cursor that drifts a row off its own label. The text boxes the")
U.log("Pokémon and item lists draw are the control: they never moved.")
U.log("the bag's USE/TOSS box is the tight one: pokered draws it a row")
U.log("shorter than the rest, so USE and TOSS fill 11 and 13 with no blank")
U.log("row at all. If TOSS is sitting on the border there, that is the bug.")
U.log("Screenshots are under " .. SHOT_DIR)
while true do
coroutine.yield()
end
end
+112
View File
@@ -0,0 +1,112 @@
-- Driver: regression coverage for #601 "Wrong dialogue when interacting
-- with Prof. Oak's last ball".
--
-- After the player picks a starter and the rival takes his, the leftover
-- ball on the lab table must show "That's PROF.OAK's last Pokémon!" --
-- pret/pokered scripts/OaksLab.asm OaksLabSelectedPokeBallScript jumps
-- every ball handler to OaksLabLastMonScript once EVENT_GOT_STARTER is
-- set (Oak turns to face the player first). The buggy port fell through
-- to _OaksLabThoseArePokeBallsText ("Those are POKé BALLs...") instead.
--
-- Scenario A (the #601 regression): with a starter already picked, talk
-- to the leftover ball -> Oak faces down, box says "last Pokémon!",
-- and no starter offer/dex appears. Fails before the fix (the box
-- says "Those are POKé BALLs").
-- Scenario B (guard): with NO starter and not escorted in, the ball still
-- says "Those are POKé BALLs". Passes before and after the fix.
--
-- Setup: flags are set directly (pick flow never runs), so all three
-- balls stay visible; the player stands left of the Charmander ball
-- (cell 6,3), the leftover slot for the Squirtle pick (rival took the
-- Bulbasaur ball). The lab battle flag is set so the rival is gone and
-- cannot intercept the talk. TextBox.new is hooked to capture the raw
-- box text.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local TextBox = require("src.render.TextBox")
local origNew = TextBox.new
local lastText
TextBox.new = function(g, text, ...)
lastText = text
return origNew(g, text, ...)
end
local function restore()
TextBox.new = origNew
end
local function setFlags(postPick)
local flags = game.save.flags or {}
game.save.flags = flags
flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
if postPick then
flags.EVENT_GOT_STARTER = true
flags.EVENT_CHOSE_SQUIRTLE = true
else
flags.EVENT_GOT_STARTER = nil
end
-- rival already fought + gone, so he cannot intercept the talk
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
end
-- Talk to the ball at cell 6,3 (Charmander slot): stand one cell left
-- facing right and press A. Returns once a TextBox has been built.
local function talkToBall()
lastText = nil
U.teleport(game, "OAKS_LAB", 5, 3, "right")
U.wait(6)
for _ = 1, 8 do
U.tap(game, "a")
for _ = 1, 30 do
if lastText then return true end
U.wait(1)
end
end
return lastText ~= nil
end
-- ---- Scenario A: leftover ball after the pick
setFlags(true)
local aBoxOpened = talkToBall()
U.wait(30) -- let the typewriter reveal the line
U.shot(game, DIR .. "/a_last_ball.png")
local aText = lastText or "<none>"
local aPass = aBoxOpened
and aText:find("last Pokémon!", 1, true) ~= nil
and aText:find("Those are", 1, true) == nil
U.log("SCENARIO A box:", aText)
U.log("SCENARIO A", aPass and "PASS" or "FAIL")
-- close the box
for _ = 1, 10 do
if game.stack:top() == game.overworld then break end
U.tap(game, "a")
U.wait(2)
end
-- ---- Scenario B: pre-escort ball text unchanged
setFlags(false)
local bBoxOpened = talkToBall()
U.wait(30)
U.shot(game, DIR .. "/b_pre_escort.png")
local bText = lastText or "<none>"
local bPass = bBoxOpened
and bText:find("ThoseArePokeBalls", 1, true) ~= nil
or bText:find("Those are", 1, true) ~= nil
U.log("SCENARIO B box:", bText)
U.log("SCENARIO B", bPass and "PASS" or "FAIL")
-- restore hooks before any assert so a failure can't leave them installed
restore()
U.log("RESULT bug601", (aPass and bPass) and "PASS" or "FAIL")
assert(aPass,
"Leftover ball after the pick must say 'That's PROF.OAK's last "
.. "Pokémon!' (no 'Those are POKé BALLs'); got: " .. aText)
assert(bPass,
"Pre-escort balls must keep the 'Those are POKé BALLs' line; got: "
.. bText)
end
@@ -0,0 +1,262 @@
-- Manual check of Yellow's two extra pic rips: PROF.OAK's own back pic in
-- the Pallet catch scene (#557, LoadPlayerBackPic's BATTLE_TYPE_PIKACHU
-- branch, pokeyellow engine/battle/core.asm:6384-6391) and the framed
-- portrait TalkToPikachu raises (#561, each PikaPicAnimScript's own base
-- frame, data/pikachu/pikachu_pic_animation.asm). Both are gated on files
-- the importer writes, so the asset check comes before any staging. No
-- POKEPORT_IDENTITY: a sandbox save dir carries no Yellow cache at all.
-- POKEPORT_DRIVER=tests/drivers/oak_pikapic_bug557_bug561_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local GameVersion = require("src.core.GameVersion")
local BattleState = require("src.battle.BattleState")
local Sprites = require("src.pokemon.Sprites")
local Pokemon = require("src.pokemon.Pokemon")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local PROF_BACK = "assets/generated/battle/profoakb.png"
local OLDMAN_BACK = "assets/generated/battle/oldmanb.png"
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
if not check("running the Yellow cache (POKEPORT_VERSION=yellow)",
GameVersion.isYellow()) then
U.log("Neither pic exists in Red or Blue. Re-run with")
U.log("POKEPORT_VERSION=yellow.")
idle()
end
-- ---- the two rips, before anything is staged ----
-- The importer writes these only when the manifest carries the symbol, and
-- both call sites fall back silently when the file is absent, so a missing
-- file looks on screen exactly like the bug being unfixed.
local oakPicOk = love.filesystem.getInfo(PROF_BACK) ~= nil
check("Oak's back pic is in the cache (" .. PROF_BACK .. ")", oakPicOk)
if not oakPicOk then
U.log("The importer skipped it: tools/rom_manifest_yellow.json has no")
U.log("ProfOakPicBack entry, so RomExtractor's gate never fires and")
U.log("Sprites.playerPath falls back to " .. OLDMAN_BACK .. ".")
U.log("Re-importing will not help until the symbol is in the manifest.")
end
local pikapicFound = {}
for script = 1, 28 do
local path = "assets/generated/pikachu/pikapic_" .. script .. ".png"
if love.filesystem.getInfo(path) then
pikapicFound[#pikapicFound + 1] = script
end
end
local pikapicOk = #pikapicFound > 0
check("the pikapic base frames are in the cache (assets/generated/pikachu/)",
pikapicOk)
if pikapicOk then
U.log("found base frames for scripts:", table.concat(pikapicFound, " "))
else
U.log("The importer skipped all 28: the Pic_e4000..Pic_f0cf4 labels are")
U.log("absent from tools/rom_manifest_yellow.json, so PikachuFollower")
U.log("keeps standing the battle front pic in the frame, which is #561")
U.log("unchanged. Not staging the talk: it cannot look right yet.")
end
U.log("tests/engine/yellow_oak_back_pikapic.lua asserts both symbol lists.")
-- ---- #557: the Pallet catch scene ----
-- Same two calls data/scripts/story2.lua:255 makes once Oak has said
-- "That was close!" Standing in Pallet Town first so the battle draws
-- over a real map, as it does in play.
U.teleport(game, "PALLET_TOWN", 10, 8, "up")
U.wait(10)
game.save.player.name = "bryan"
local backPath = Sprites.playerPath(game.data, "back",
{ kind = "battle", demo = true, oakDemo = true })
U.log("the demo resolves its back pic to:", tostring(backPath))
check("it is Oak's pic, not the old man's", backPath == PROF_BACK)
local battle = BattleState.newWild(game, "PIKACHU", 5)
battle:makeOldManDemo("PROF.OAK")
check("the thrower is named PROF.OAK", battle.demoName == "PROF.OAK")
check("the battle asks for the BATTLE_TYPE_PIKACHU back pic",
battle.oakDemo == true)
-- record what the scripted throw actually prints, so the name half can be
-- judged without reading glyph codes off the canvas
local said = {}
local realSay = battle.say
battle.say = function(self, text)
said[#said + 1] = tostring(text)
return realSay(self, text)
end
game.stack:push(battle)
U.wait(10)
-- clear the "Wild PIKACHU appeared!" box; the scripted cursor script only
-- starts once the intro chrome is gone
for _ = 1, 90 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(4)
end
if not check("the demo battle reached its scripted menu",
battle.phase == "menu") then
U.log("phase is", tostring(battle.phase), "- the intro never cleared.")
idle()
end
U.wait(20)
if U.shot(game, SHOT_DIR .. "/bug557_back_pic.png") then
U.log("captured", SHOT_DIR .. "/bug557_back_pic.png")
end
-- The other half of the issue title, "referred to as Pikachu".
-- DisplayBattleMenu's simulated branch puts BATTLE_MENU_TEMPLATE on screen
-- and nothing else (data/text_boxes.asm:31 -- FIGHT/PKMN/ITEM/RUN from
-- column 8), so no name is printed at all: there is no player mon in this
-- battle to name. The classic layout ports that branch; WideBattle's menu
-- has no demo case and prints "What will <battle.player.name> do?" over the
-- hidden placeholder battler, which is a level 5 PIKACHU.
local options = game.save.options or {}
U.log("battle layout option:", tostring(options.battleLayout or "classic"))
local wide = battle:isWideBattleLayout()
check("the layout on screen leaves the scripted menu unnamed", not wide)
if wide then
U.log("The wide layout is on, so the menu reads \"What will PIKACHU do?\"")
U.log("beside Oak's throw. Switch OPTIONS to the classic battle layout")
U.log("and re-run to see the box the original draws.")
end
-- the rest runs itself: 130 frames of cursor, the forced ITEM list, then
-- the throw. Never tap through it -- the timing is the port of
-- DisplayBattleMenu's simulated keystrokes.
local function thrownLine()
for _, text in ipairs(said) do
if text:find("POK", 1, true) and text:find("PROF.OAK", 1, true) then
return text
end
end
return nil
end
for _ = 1, 600 do
if thrownLine() then break end
U.wait(1)
end
local line = thrownLine()
check("the throw is announced under PROF.OAK's name", line ~= nil)
if line then U.log("the box reads:", (line:gsub("\n", " / "))) end
U.wait(45)
if U.shot(game, SHOT_DIR .. "/bug557_throw.png") then
U.log("captured", SHOT_DIR .. "/bug557_throw.png")
end
if oakPicOk then
U.log("Both #557 shots are taken. The back pic in the lower left is the")
U.log("man throwing the ball: Oak in his lab coat, and the throw line")
U.log("names PROF.OAK. The near miss is a back pic that reads as a")
U.log("person and looks plausible while still being oldmanb.png --")
U.log("compare against the catch tutorial north of Viridian, which")
U.log("SHOULD be the old man and proves the demo path works at all.")
else
U.log("The shots are evidence of the FAIL, not of a fix: the back pic in")
U.log("the lower left is the bald OLD MAN from the Route 5 tutorial,")
U.log("exactly as #557 reported him. The throw line above it does name")
U.log("PROF.OAK, so the thrower half is right and only the pic is stuck.")
end
-- ---- #561: the framed portrait ----
if not pikapicOk then
U.log("Skipping the Pikachu portrait: see the FAIL above.")
idle()
end
for _ = 1, 300 do
if game.stack:top() ~= battle then break end
U.wait(1)
end
U.wait(20)
-- ShouldPikachuSpawn (pokeyellow engine/pikachu/pikachu_follow.asm): the
-- lab gift happened and a healthy Pikachu leads the party. Pallet's
-- objects sit at (10,4), (3,8) and (11,14), so (10,8) and the road cells
-- around it are clear.
game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) }
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(12)
local ow = game.overworld
local function follower()
for _, n in ipairs(ow.npcs or {}) do
if n.pikachuFollower then return n end
end
return nil
end
local npc = follower()
if not check("the follower spawned", npc ~= nil) then idle() end
-- one step then a turn back, so the follower is on the cell just vacated
local DIRS = { { "down", 0, 1 }, { "up", 0, -1 },
{ "left", -1, 0 }, { "right", 1, 0 } }
local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" }
local stepDir
for _, d in ipairs(DIRS) do
local cx, cy = ow.player.cellX + d[2], ow.player.cellY + d[3]
if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy)
and not ow:npcAtCell(cx, cy) then
stepDir = d[1]
break
end
end
if not check("a walkable neighbour to step into exists", stepDir ~= nil) then
idle()
end
U.hold(game, stepDir, 24)
U.wait(6)
U.tap(game, OPPOSITE[stepDir])
U.wait(6)
local function facingFollower()
local fx, fy = ow.player:facingCell()
return ow:npcAtCell(fx, fy) == npc
end
if not facingFollower() then
for _, d in ipairs(DIRS) do
if npc.cellX == ow.player.cellX + d[2]
and npc.cellY == ow.player.cellY + d[3] then
U.tap(game, d[1])
U.wait(6)
break
end
end
end
check("player is facing the follower", facingFollower())
U.tap(game, "a")
U.wait(8)
local emote = ow.emote
if not check("the A press raised a portrait", emote ~= nil and emote.pikaPic) then
U.log("Nothing answered the press, which is #407 territory, not #561.")
idle()
end
U.log("the frame is drawing:", tostring(emote.pikaPic))
check("it is this script's own base frame, not the battle front pic",
emote.pikaPic:find("pikachu/pikapic_", 1, true) ~= nil)
if U.shot(game, SHOT_DIR .. "/bug561_portrait.png") then
U.log("captured", SHOT_DIR .. "/bug561_portrait.png")
end
U.log("The portrait box is up over the map now. Inside the 5x5 frame is a")
U.log("posed Pikachu drawn for that emotion, not the battle front pic")
U.log("standing to attention -- that stand-in is the screenshot on #561.")
U.log("Face it and press A again for another emotion; the pose should")
U.log("change with it. A frame that never changes between emotions is the")
U.log("near miss: the path resolved but every script picked one base.")
idle()
end

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