Compare commits

...

49 Commits

Author SHA1 Message Date
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
bryanthaboi b17dce232e Merge pull request #547 from bryanthaboi/dev
bug squashing and what not
2026-07-31 20:21:37 -04:00
bryanthaboi e1b20723fd Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-07-31 20:20:38 -04:00
bryanthaboi fdab15a6dc CLOSES #503, CLOSES #511, CLOSES #515, CLOSES #518, CLOSES #522, CLOSES #523, CLOSES #525, CLOSES #528, CLOSES #529, CLOSES #533, CLOSES #535, CLOSES #536 2026-07-31 20:20:37 -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
bryanthaboi 4916fc8f4b Merge pull request #532 from TitaniteScale/symlink-mod-support
Recognize symlinked mod dirs on Linux (mods/ dev workflow)
2026-07-31 15:22:48 -04:00
Jake Eaker fc19e58678 added symlink support for mods 2026-07-31 13:47:53 -04: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
157 changed files with 11892 additions and 541 deletions
+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
}
+11
View File
@@ -108,6 +108,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.
+6 -1
View File
@@ -1,4 +1,9 @@
function love.conf(t)
-- PhysFS ignores symlinks unless told otherwise, so a mod dev-linked into
-- mods/ (ln -s, matching the mklink /J workflow on Windows) is invisible
-- to love.filesystem.getDirectoryItems without this.
love.filesystem.setSymlinksEnabled(true)
local editor = os.getenv("POKEPORT_EDITOR") == "1"
local developer = os.getenv("POKEPORT_DEV") == "1"
if arg then
@@ -44,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
+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!",
}),
},
},
}
@@ -0,0 +1,95 @@
-- Viridian School House's two readables (data/events/hidden_events.asm,
-- hidden_events_for VIRIDIAN_SCHOOL_HOUSE):
-- hidden_text_predef 3, 0 PrintBlackboardLinkCableText, ViridianSchoolBlackboard
-- hidden_text_predef 3, 4 PrintNotebookText, ViridianSchoolNotebook
-- tools/extract/field.py only parses `hidden_event` rows, so neither
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
-- were dead A presses (#503). Same hook shape, and the same sibling asm
-- file, as the Celadon roof house in data/scripts/celadon_eevee.lua (#391);
-- hidden_text_predef spends the facing byte on the tx_pre id, so neither
-- tile gates on facing.
local Menu = require("src.ui.Menu")
local TextBox = require("src.render.TextBox")
-- ViridianSchoolBlackboard (engine/events/hidden_events/school_blackboard.asm):
-- StatusAilmentText1/2 are the two columns of the 12x8 box at the top left
-- (hlcoord 0, 0 + `lb bc, 6, 10`); picking a status prints its
-- ViridianBlackboardStatusPointers entry and jumps back to .blackboardLoop,
-- QUIT or B falls through to .exitBlackboard.
local STATUS_LABELS = {
{ " SLP", "_ViridianBlackboardSleepText" },
{ " PSN", "_ViridianBlackboardPoisonText" },
{ " PAR", "_ViridianBlackboardPrlzText" },
{ " BRN", "_ViridianBlackboardBurnText" },
{ " FRZ", "_ViridianBlackboardFrozenText" },
}
local function blackboard(game)
local text = game.data.text or {}
local items, showMenu, askHeading
function showMenu()
game.stack:push(Menu.new(game, items,
{ tx = 0, ty = 0, tw = 12, th = 8, rowStep = 1 }))
end
-- ViridianSchoolBlackboardText2 is reprinted on every .blackboardLoop
-- pass, immediately before HandleMenuInput
function askHeading()
game.stack:push(TextBox.new(game,
text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?",
showMenu))
end
items = {}
for i, row in ipairs(STATUS_LABELS) do
local label, key = row[1], row[2]
items[i] = { label = label, onSelect = function()
game.stack:push(TextBox.new(game, text[key] or label, askHeading))
end }
end
-- no onSelect: Menu's own pop closes the box, matching .exitBlackboard
items[#items + 1] = { label = " QUIT" }
game.stack:push(TextBox.new(game,
text._ViridianSchoolBlackboardText1
or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.",
askHeading))
end
-- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm):
-- pages 1-3 each end in TurnPageSchoolNotebook (TurnPageText + YesNoChoice)
-- and NO stops the read; page 4 turns without asking and runs straight into
-- page 5, the girl catching you at it.
local function notebook(game)
local text = game.data.text or {}
local function page(n, after)
return TextBox.new(game, text["_ViridianSchoolNotebookText" .. n] or "", after)
end
local function turnPage(nextPage)
return function()
game.stack:push(TextBox.new(game, text._TurnPageText or "Turn the page?",
nil, { choice = function(yes)
if yes then game.stack:push(nextPage()) end
end }))
end
end
local function page5() return page(5) end
local function page4() return page(4, function() game.stack:push(page5()) end) end
local function page3() return page(3, turnPage(page4)) end
local function page2() return page(2, turnPage(page3)) end
game.stack:push(page(1, turnPage(page2)))
end
return {
VIRIDIAN_SCHOOL_HOUSE = {
onInteract = function(game, ow, fx, fy)
if fx == 3 and fy == 0 then
blackboard(game)
return true
end
if fx == 3 and fy == 4 then
notebook(game)
return true
end
return false
end,
},
}
+1
View File
@@ -60,6 +60,7 @@ local files = {
"data.scripts.flavor.victory_road_2f",
"data.scripts.flavor.viridian_city",
"data.scripts.flavor.viridian_nickname_house",
"data.scripts.flavor.viridian_school_house", -- #503
"data.scripts.flavor.wardens_house",
}
+16 -8
View File
@@ -143,17 +143,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
+14 -3
View File
@@ -647,7 +647,12 @@ M.WARDENS_HOUSE = {
TEXT_WARDENSHOUSE_WARDEN = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_HM04" }, -- 2
{ "jump_if_true", 13 }, -- 3
-- #535: previously jumped to the same silent-end target as the
-- give-then-thank fallthrough (row 13), so the warden said nothing
-- on every visit after the trade. pokered's .got_item branch
-- (scripts/WardensHouse.asm) instead prints HM04ExplanationText,
-- so route here to the new row 17 that does the same.
{ "jump_if_true", 17 }, -- 3
{ "check_item", "GOLD_TEETH" }, -- 4
{ "jump_if_false", 15 }, -- 5
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
@@ -658,9 +663,15 @@ M.WARDENS_HOUSE = {
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
{ "jump", 16 }, -- 13 (already got it)
{ "jump", 16 }, -- 14 (unused)
{ "jump", 18 }, -- 13 (already got it this convo; jp .done)
{ "jump", 18 }, -- 14 (unused)
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
{ "jump", 18 }, -- 16 (#535: skip the new explanation row below)
-- #535: pokered .got_item branch (scripts/WardensHouse.asm) --
-- printed on every subsequent talk once EVENT_GOT_HM04 is set.
-- Text is _WardensHouseWardenHM04ExplanationText (text/WardensHouse.asm):
-- HM04 teaches Strength, and hints at the Safari Zone secret house.
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 17
},
},
}
+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.
+12 -1
View File
@@ -790,8 +790,19 @@ local function bikeGateGuard(coords, stopText, explainText)
local t = text(game)
push(game, t[stopText] or "Hey! Wait up!", function()
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
-- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript
-- (scripts/Route16Gate1F.asm, Route18Gate1F.asm) simulate one
-- PAD_RIGHT step after the refusal text, and only clear
-- wJoyIgnore / hand control back once that step finishes
-- (PlayerMovingRightScript). Without it the player was left
-- parked beside the guard's counter with no way past. #518
local function shoveRight()
ow:scriptMove(ow.player, "right", 1)
end
if dist > 0 then
ow:scriptMove(ow.player, "up", dist)
ow:scriptMove(ow.player, "up", dist, shoveRight)
else
shoveRight()
end
end)
end)
+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.
+72 -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
@@ -157,6 +192,13 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
also exercises real UDP pairing.
Red, Blue, and Yellow copies link with each other, as the real cable
does. The compatibility fingerprint hashes only data a link mode can
actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the
only R/B/Y link-surface difference) no longer read as different games
(issue #511). Moving the fingerprint is a link parity change: builds
from before this fix will refuse to pair with builds after it.
## Fair play in link and online matches
A link session is decided by the battle and nothing else, so for its
@@ -273,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
@@ -338,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.
@@ -390,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.
+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 -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>
+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 {} \;
+153 -4
View File
@@ -50,7 +50,7 @@ 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
@@ -65,6 +65,11 @@ 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 +83,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 +94,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
@@ -228,7 +238,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 +336,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 +349,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 +456,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
@@ -455,6 +600,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 +671,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}"
+204 -43
View File
@@ -291,10 +291,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 +652,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 +722,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
@@ -1189,8 +1248,11 @@ function BattleState:enter()
-- without a transition (link battles, scripted pushes)
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
-- sides slide in as black silhouettes. The original scrolls SCX from
-- $90 to 0 two pixels per frame (72 frames); the port covers the full
-- 160px screen width, so 2px/frame is an 80-frame slide (slide offset is
-- introSlide*2 below). The trainer pics stay up until the send-outs.
self.introSlide = 80
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 +1276,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 +1292,16 @@ 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
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
@@ -2663,6 +2731,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 +2774,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
@@ -3216,7 +3327,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 +3408,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 +3484,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 +3607,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 +4018,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
@@ -4304,6 +4453,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 +5230,7 @@ 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
local slide = (self.introSlide or 0) * 2 -- intro slide-in offset (2px/frame)
if self:colorMode() then
-- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the
+6
View File
@@ -93,6 +93,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end
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
-- runs the explode effect ("even if Explosion or Selfdestruct
-- missed, its effect still needs to be activated", core.asm:3223),
-- so the user faints against a mid-Fly/Dig target too (#528)
if record and record.onMiss then record.onMiss(ctx, "invulnerable") end
return
end
+39 -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,40 @@ 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
local slide = (battle.introSlide or 0) * 2
-- 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)
+5 -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,
+44
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)
@@ -475,8 +491,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 +517,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)
@@ -634,6 +661,23 @@ function Game: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
+6 -1
View File
@@ -241,6 +241,11 @@ function SaveData.defaultOptions()
videoMode = "windowed",
-- 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 +1058,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
+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({
+255 -66
View File
@@ -1,12 +1,34 @@
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.
local CACHE_FORMAT = "rom-cache-v8:"
-- v9: Yellow audio re-anchored on pokeyellow.sym (#522) -- stale caches
-- carry Red's bank $1f header, wave-table, and CryData offsets.
local CACHE_FORMAT = "rom-cache-v9:"
-- The completion marker is written under each version's cache prefix
-- (rom-cache.complete for Red, blue/rom-cache.complete for Blue).
local MARKER_PATH = "rom-cache.complete"
@@ -39,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 = {
@@ -553,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.
@@ -609,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.
@@ -790,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
@@ -976,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
@@ -984,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
@@ -1037,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
@@ -1047,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." }
@@ -1089,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,
@@ -1126,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
@@ -1135,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).
@@ -1181,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
@@ -1223,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()
@@ -1247,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
@@ -1267,6 +1386,7 @@ function RomImporter:_cycleTab(delta)
self._slotPress = nil
self._modPress = nil
self._findSearchFocus = false
self:_disarmTextInput()
end
function RomImporter:_updatePadCursor(dt)
@@ -1296,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
@@ -1678,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
@@ -1697,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
@@ -1722,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 } }
@@ -1739,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
@@ -1762,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
@@ -1788,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 },
@@ -1810,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
@@ -1836,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.
@@ -1866,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
@@ -1877,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.
@@ -2055,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
@@ -2091,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)
@@ -2137,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)
@@ -2183,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"),
@@ -2193,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
@@ -2201,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))
@@ -2243,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))
@@ -2288,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))
@@ -2345,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))
@@ -2354,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)
@@ -2461,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
@@ -2521,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
@@ -2628,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
@@ -2749,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
@@ -2781,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
@@ -2797,6 +2948,7 @@ function RomImporter:keypressed(key)
self:_commitRename()
elseif key == "escape" then
self._rename = nil
self:_disarmTextInput()
end
return
end
@@ -2807,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
@@ -2837,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
@@ -3390,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
@@ -3397,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
@@ -3424,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)
@@ -3575,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,
@@ -4467,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
+7 -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")
@@ -433,6 +432,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if battle then
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
-- FishingInit (engine/items/item_effects.asm): cp wWalkBikeSurfState, 2
-- (surfing) sets carry, and every ItemUseXRod does jp c, ItemUseNotTime
-- on that carry -- surfing refuses the rod with the same OAK text as
-- the mid-battle case above, no rod-specific message (#533)
if ow and ow.player and ow.player.surfing then
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
return "fish", itemId
end
+8 -1
View File
@@ -128,7 +128,14 @@ end
-- ------- the link surface
local SPECIES_FIELDS = { "baseStats", "types", "catchRate", "baseExp",
-- catchRate stays out (#511): no link mode ever reads it -- a ball thrown
-- in a link battle is a trainer-battle throw and always refused (pokered
-- engine/items/item_effects.asm ItemUseBall), and the trade rebuild never
-- touches it. Hashing it split Red/Blue from Yellow, whose only link
-- surface delta is the Dragonair/Dragonite catch-rate bytes
-- (data/pokemon/base_stats/dragonair.asm db 45 vs 27, dragonite.asm 45
-- vs 9), when the real cable links R/B/Y freely.
local SPECIES_FIELDS = { "baseStats", "types", "baseExp",
"growthRate", "evolutions" }
local MOVE_FIELDS = { "power", "type", "accuracy", "pp", "effect", "category",
"priority", "highCrit", "fixedDamage", "multiHit",
+9 -12
View File
@@ -178,19 +178,16 @@ end
-- ------- subset negotiation
-- the species and moves this party actually references, so the exchange
-- stays small (six mons) instead of shipping the whole catalog
-- every record hash, not just this party's slice: the receiver filters
-- its OWN party against these (eligibleParty), so a message limited to the
-- sender's party read "not on the other game" for every species the two
-- parties did not happen to share, and a subset trade between different
-- games showed neither side (#511). The full catalog is ~300 short hash
-- strings -- still one message.
function Protocol.recordsMessage(data, party)
local species = Fingerprint.records(data, "pokemon")
local moves = Fingerprint.records(data, "moves")
local outSpecies, outMoves = {}, {}
for _, mon in ipairs(party or {}) do
if species[mon.species] then outSpecies[mon.species] = species[mon.species] end
for _, mv in ipairs(mon.moves or {}) do
if moves[mv.id] then outMoves[mv.id] = moves[mv.id] end
end
end
return { type = "records", pokemon = outSpecies, moves = outMoves }
return { type = "records",
pokemon = Fingerprint.records(data, "pokemon"),
moves = Fingerprint.records(data, "moves") }
end
-- a mon may cross the wire only if both peers rebuild it identically: the
+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
+3 -1
View File
@@ -223,7 +223,9 @@ local function discover()
for _, name in ipairs(fs.getDirectoryItems("mods")) do
local path = "mods/" .. name
local info = fs.getInfo(path)
if info and info.type == "directory" then
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
-- setSymlinksEnabled(true); see the matching note in Loader:_discover.
if info and (info.type == "directory" or info.type == "symlink") then
local raw = fs.read(path .. "/manifest.json")
if raw then
local manifest = decodeManifest(raw, path)
+5 -1
View File
@@ -206,7 +206,11 @@ function Loader:_discover()
for _, name in ipairs(self.fs.getDirectoryItems(root)) do
local path = root .. "/" .. name
local info = self.fs.getInfo(path)
if info and info.type == "directory" then
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
-- setSymlinksEnabled(true) -- PhysFS never resolves the symlink's
-- own getInfo, only traversal into it. readManifest below still
-- correctly no-ops on a symlink that isn't a directory.
if info and (info.type == "directory" or info.type == "symlink") then
local manifest, err = readManifest(self.fs, path)
if manifest then
if self.mods[manifest.id] then
+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,
+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"
+75 -32
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"
@@ -474,6 +474,41 @@ 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
-- 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
@@ -506,6 +541,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
@@ -560,38 +625,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 +641,11 @@ 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)
if love.system and love.system.getOS and love.system.getOS() == "iOS" 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
+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
+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
+3 -2
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
+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
+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)
+18 -4
View File
@@ -108,20 +108,34 @@ function Menu:draw()
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
+27
View File
@@ -20,6 +20,7 @@ local GameSpeed = require("src.core.GameSpeed")
local GameVersion = require("src.core.GameVersion")
local VideoMode = require("src.core.VideoMode")
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")
@@ -202,6 +203,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")
@@ -432,10 +448,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
+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()
+99 -12
View File
@@ -148,6 +148,37 @@ local SPEED_BARS = {
{ 0xE0, 0x80, 4 }, { 0xF0, 0x90, 2 },
}
-- PlayIntroScene's .loop (pokeyellow engine/movie/intro_yellow.asm) ends
-- every iteration with its own `call DelayFrame`, so one scene handler costs
-- one frame plus whatever DelayFrame calls it makes itself. The port runs a
-- handler inside a single update() tick, so every one of those internal
-- DelayFrame calls has to be spent explicitly or the movie runs short of
-- hardware. Three places owe frames:
--
-- SETUP_SCENE_DELAY scenes 2/4/6/8/10/12 open with
-- YellowIntro_BlankPalsDelay2AndDisableLCD: rBGP/rOBP0/
-- rOBP1 to $00 (color 0 in every shade -- the lightest,
-- white on DMG), two DelayFrame calls, then DisableLCD.
-- Two internal plus the loop's own is a 3-frame
-- iteration, after which the next wait scene takes its
-- first timer decrement. Func_f9e9a restores rBGP $e4
-- at the end of the same handler.
-- HEAD_FRAMES PlayIntroScene spends a DelayFrame between
-- InitYellowIntroGFXAndMusic (which ends in PlayMusic)
-- and the first .loop pass, and YellowIntroScene0 then
-- owns a whole iteration of its own.
-- EXIT_FRAMES .go_to_title_screen blanks the palettes and spends
-- four DelayFrame calls clearing the tilemap and OAM
-- before the title screen is built.
--
-- These restore the movie's real elapsed length; they do NOT lengthen the
-- intro song. title.asm's StopAllMusic before MUSIC_TITLE_SCREEN is
-- unconditional (only PikachuCry1 is waited on), so hardware cuts
-- Music_YellowIntro off mid-phrase too (#523).
local SETUP_SCENE_DELAY = 3
local HEAD_FRAMES = 2
local EXIT_FRAMES = 4
-- scene-6 sine (YellowIntro_Copy8BitSineWave.SineWave), signed SCY deltas
local WAVE = { 0, 0, 1, 2, 2, 3, 3, 3, 4, 3, 3, 3, 2, 2, 1, 0,
0, 0, -1, -2, -2, -3, -3, -3, -4, -3, -3, -3, -2, -2, -1, 0 }
@@ -208,6 +239,8 @@ function YellowIntro.new(game, onDone)
self.palName = "MEWMON" -- PalPacket_Generic
self.objects = {}
self.cloudFrame = 0
self.pendingThen = nil -- what enterDelay runs once pendingDelay hits 0 (#523)
self.pendingDelay = 0
self.atlas1 = tryImage("assets/generated/intro/yellow_intro_1.png")
self.atlas2 = tryImage("assets/generated/intro/yellow_intro_2.png")
@@ -559,6 +592,24 @@ function YellowIntro:startScene(scene)
end
end
-- Spend `frames` real frames, then run fn: the port's stand-in for the
-- DelayFrame calls a pokeyellow scene handler makes inside its own
-- PlayIntroScene .loop iteration (#523). fn may be nil to just burn time.
function YellowIntro:enterDelay(frames, fn)
self.pendingDelay = frames
self.pendingThen = fn
end
-- YellowIntro_BlankPalsDelay2AndDisableLCD blanks the palettes for the
-- delay; Func_f9e9a puts rBGP back to $e4 at the tail of the same handler.
function YellowIntro:enterSetupScene(scene)
self.bgp = 0x00
self:enterDelay(SETUP_SCENE_DELAY, function()
self.bgp = 0xE4
self:startScene(scene)
end)
end
function YellowIntro:enter()
if self.pre then return end -- pre-roll first; beginScenes takes over
self:beginScenes()
@@ -571,7 +622,11 @@ function YellowIntro:beginScenes()
local song = songs and (songs.Music_YellowIntro and "Music_YellowIntro"
or songs.Music_IntroBattle and "Music_IntroBattle")
if song then pcall(Music.play, data, song, false) end
self:startScene(0)
-- HEAD_FRAMES: the DelayFrame between PlayMusic and the first .loop pass
-- plus YellowIntroScene0's own iteration. new() already laid down the
-- Func_f9e5f letterbox that InitYellowIntroGFXAndMusic writes, so these
-- frames show what hardware shows: the letterbox with no pika yet.
self:enterDelay(HEAD_FRAMES, function() self:startScene(0) end)
end
-- The movie's exit never stops the song (intro_yellow.asm PlayIntroScene
@@ -586,16 +641,44 @@ function YellowIntro:finish()
if self.onDone then self.onDone() end
end
-- Both scene-loop exits (scene 17 running out, and the A/B/START skip) land
-- on .go_to_title_screen, which calls YellowIntro_BlankPalettes and then
-- spends EXIT_FRAMES DelayFrame calls clearing the tilemap and the OAM
-- buffers before the title screen is built (#523). The pre-roll's own skip
-- is IntroMovie's path, not this one, and still finishes immediately.
function YellowIntro:exitToTitle()
if self.finished or self.exiting then return end
self.exiting = true
self.bgp = 0x00
self:clearObjects()
self:enterDelay(EXIT_FRAMES, function() self:finish() end)
end
function YellowIntro:update(dt)
if self.finished then return end
if self.pre then
self.pre:update(dt)
return
end
-- Ahead of the skip poll on purpose: JoypadLowSensitivity runs once at the
-- top of each .loop iteration, never inside a handler, so hardware is deaf
-- for the DelayFrame calls these ticks stand in for (#523).
if self.pendingDelay > 0 then
self.pendingDelay = self.pendingDelay - 1
if self.pendingDelay == 0 then
local fn = self.pendingThen
self.pendingThen = nil
if fn then fn() end
end
self:updateObjects()
if self.bgDirty then self:rebuildBgCanvas() end
return
end
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b")
or input:wasPressed("start") then
self:finish()
self:exitToTitle()
return
end
@@ -605,7 +688,7 @@ function YellowIntro:update(dt)
self.timer = self.timer - 1
else
self:clearObjects()
self:startScene(scene + 1)
self:enterSetupScene(scene + 1) -- BlankPalsDelay2AndDisableLCD (#523)
end
elseif scene == 3 then
if self.timer > 0 then
@@ -613,7 +696,7 @@ function YellowIntro:update(dt)
if self.scx ~= 0x68 then self.scx = self.scx + 4 end
else
self:clearObjects()
self:startScene(4)
self:enterSetupScene(4) -- BlankPalsDelay2AndDisableLCD (#523)
end
elseif scene == 7 then
if self.timer > 0 then
@@ -625,7 +708,7 @@ function YellowIntro:update(dt)
self.wave[255] = first
else
self:clearObjects()
self:startScene(8)
self:enterSetupScene(8) -- BlankPalsDelay2AndDisableLCD (#523)
end
elseif scene == 11 then
if self.timer > 0 then
@@ -640,7 +723,7 @@ function YellowIntro:update(dt)
self.timer = self.timer - 1
else
self:clearObjects()
self:startScene(12)
self:enterSetupScene(12) -- BlankPalsDelay2AndDisableLCD (#523)
end
elseif scene == 13 then
if self.timer > 0 then
@@ -656,13 +739,17 @@ function YellowIntro:update(dt)
if v then
self.bgp = v
else
-- .expired: everything despawns, letterbox returns, logo/face
-- object $7 appears for the strobe scene
-- .expired: everything despawns and the letterbox returns, then the
-- handler spends three DelayFrame calls with hAutoBGTransferEnabled
-- pushing that rebuilt tilemap to VRAM before it restores rBGP $e4
-- and spawns the logo/face object $7 for the strobe scene (#523).
self:clearObjects()
self:bgLetterbox()
self.bgp = 0xE4
self:spawn(7, 0x58, 0x58)
self:startScene(15)
self:enterDelay(3, function()
self.bgp = 0xE4
self:spawn(7, 0x58, 0x58)
self:startScene(15)
end)
end
elseif scene == 15 then
if self.timer > 0 then
@@ -687,7 +774,7 @@ function YellowIntro:update(dt)
if self.timer > 0 then
self.timer = self.timer - 1
else
self:finish()
self:exitToTitle()
return
end
end
+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 },
+140 -22
View File
@@ -360,6 +360,36 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
else
self.player = Player.new(Game.data, x, y, facing)
end
-- boot only: the original persists the surf state. wWalkBikeSurfState
-- (ram/wram.asm) lives inside wMainDataStart..wMainDataEnd, which
-- engine/menus/save.asm block-copies into sMainData on save and back out
-- on load (sram.asm declares sMainData as `ds wMainDataEnd -
-- wMainDataStart`), and Continue never clears it -- the only `xor a /
-- ld [wWalkBikeSurfState], a` on that path is the cable club's. Restore
-- it here, before Music.playMap reads it below and before
-- PikachuFollower.onMapEntered, matching LoadMapData calling
-- LoadPlayerSpriteGraphics ahead of PlayDefaultMusic (home/overworld.asm,
-- home/audio.asm). Without this the player resumed on foot on a water
-- cell, which softlocks here: Collision.canMove (src/world/Collision.lua)
-- picks land tile-pairs whenever mover.surfing is falsy, and land
-- tile-pairs never permit stepping off a water cell (#536). Same
-- boot-only shape as the refreshStandingOnWarp door-mat restore (#378).
if opts and opts.via == "boot" then
local ps = Game.save and Game.save.player
if ps and ps.surfing ~= nil then
self.player.surfing = ps.surfing and true or false
else
-- saves written before #536 carry no flag. Map:isWaterCell alone is
-- not self-sufficient (see src/world/Map.lua: water and shore share
-- one lookup, and no tileset stamps waterTiles, so tile $14 -- a
-- walkable floor in HOUSE/GATE/LOBBY/MANSION/MUSEUM -- reads as
-- water), so gate it the way facingIsShoreOrWater does and require a
-- cell you could not be standing on upright.
self.player.surfing = self:tilesetHasWater()
and not self.map:isWalkableCell(x, y)
and self.map:isWaterCell(x, y)
end
end
-- crossConnection re-arms this after setMap; clear so a warp/reload
-- cannot leave a stale deferred PlayMapMusic pending
self.pendingSeamMusic = nil
@@ -1058,19 +1088,38 @@ function OverworldState:handleInput()
-- OverworldLoop (home/overworld.asm) gates ALL of JoypadOverworld on
-- wWalkCounter == 0 ("if the player sprite has not yet completed the
-- walking animation" it jumps straight to .moveAhead): A, START and
-- direction initiation are only ever looked at while the player stands
-- on a tile, and a button pressed mid-step is simply never seen.
-- Without this gate a mid-step A/START pushed its TextBox/StartMenu
-- right there and froze Red between tiles, mid-animation (#286). Held
-- directions need no buffering -- isDown below picks them up on the
-- landing frame.
if self.player.moving then return end
-- direction initiation are only ever ACTED ON while the player stands on
-- a tile. Without this gate a mid-step A/START pushed its TextBox/
-- StartMenu right there and froze Red between tiles, mid-animation
-- (#286). Held directions need no buffering -- isDown below picks them
-- up on the landing frame.
--
-- The original defers the poll rather than discarding it, though. Joypad
-- (engine/joypad.asm _Joypad) computes hJoyPressed against hJoyLast and
-- advances hJoyLast only when something calls it; the mid-step path never
-- does, and vblank's per-frame ReadJoypad refreshes hJoyInput alone.
-- hJoyLast is frozen for the whole animation, so a button pressed
-- mid-step and STILL HELD when the step lands reads as a fresh press at
-- the next poll -- one released before then is genuinely lost. Dropping
-- the edge outright made START a coin flip on the Cycling Road roll,
-- where the pull below re-arms a step on the single idle frame in
-- bikeStepFrames (#525).
if self.player.moving then
local held = self.joyLatch
if not held then held = {}; self.joyLatch = held end
if input:wasPressed("a") then held.a = true end
if input:wasPressed("start") then held.start = true end
return
end
local latch = self.joyLatch
self.joyLatch = nil
if input:wasPressed("a") then
if input:wasPressed("a") or (latch and latch.a and input:isDown("a")) then
self:interact()
return
end
if input:wasPressed("start") then
if input:wasPressed("start")
or (latch and latch.start and input:isDown("start")) then
require("src.core.Sound").play(Game.data, "Start_Menu")
Screens.push(Game, "StartMenu")
return
@@ -1106,6 +1155,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
@@ -1754,7 +1810,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
@@ -1842,9 +1898,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))
@@ -2374,7 +2441,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
@@ -2509,8 +2576,7 @@ function OverworldState:openPC(onDone)
table.insert(items, {
label = Strings("PROF.OAK's PC"),
onSelect = function()
self:dexRating()
done()
self:openOaksPC(done)
end,
})
end
@@ -2537,11 +2603,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
@@ -2558,7 +2654,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
@@ -3780,7 +3884,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
@@ -3788,7 +3900,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",
@@ -4519,9 +4632,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
@@ -4561,6 +4674,11 @@ function OverworldState:captureSave(save)
save.player.x = self.player.cellX
save.player.y = self.player.cellY
save.player.facing = self.player.facing
-- wWalkBikeSurfState (ram/wram.asm) sits inside the wMainDataStart..
-- wMainDataEnd range engine/menus/save.asm block-copies into sMainData,
-- so the original saves and restores the surf state; setMap's boot path
-- reads this back (#536).
save.player.surfing = self.player.surfing and true or false
end
return OverworldState
+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
+46 -6
View File
@@ -12,10 +12,24 @@ local Player = {}
Player.__index = Player
local STEP_FRAMES = 16
-- a turn in place holds for the ~2 frames the original spends on the
-- extra OverworldLoop pass (home/overworld.asm .handleDirectionButtonPress
-- returns to the loop without moving after a direction change)
local TURN_FRAMES = 2
-- 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 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)
@@ -63,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
@@ -71,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
@@ -0,0 +1,99 @@
-- Driver: opening the START menu while the bike force-rolls down Cycling
-- Road (#525). src/world/OverworldController.lua handleInput() used to
-- drop A/START outright while self.player.moving, so a press that landed
-- mid-step and was still held when the step completed vanished -- on
-- Cycling Road's forced roll, where a step re-arms on the single idle
-- frame between steps, that made START a coin flip. The fix latches a
-- still-held press and acts on it on the landing frame instead
-- (see tests/parity_midstep_buttons.lua for the mechanical half).
--
-- POKEPORT_DRIVER=tests/drivers/cycling_road_menu_bug525_test.lua \
-- POKEPORT_IDENTITY=bug525 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
-- 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 function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- Same clear stretch bug255 uses: x=2 is open road from y=8 past y=34.
local MAP, START_X, START_Y = "ROUTE_17", 2, 20
game.save.onBike = true
U.teleport(game, MAP, START_X, START_Y, "down")
U.wait(15)
local ow = game.overworld
check("standing on Cycling Road, on the bike",
ow.map.id == MAP and game.save.onBike)
-- hands off the pad: the slope pulls the bike south on its own
-- (field.forcedMovement.slopeMaps, same mechanism bug255 proved)
U.wait(20)
check("the roll is already under way", ow.player.moving == true
or ow.player.cellY > START_Y)
U.shot(game, DIR .. "/bug525_1_rolling.png")
-- press START mid-step and keep it held through the landing frame, the
-- exact shape a real thumb makes reaching for the menu while rolling
local pressedMidStep = false
local guard = 0
while guard < 60 do
guard = guard + 1
if ow.player.moving and ow.player.progress
and ow.player.progress > 2 and not pressedMidStep then
table.insert(game.input.pressQueue, "start")
game.input.state.start = true
pressedMidStep = true
U.log("pressed START mid-step at progress", ow.player.progress)
end
coroutine.yield()
if getmetatable(game.stack:top()) ~= nil
and game.stack:top() ~= ow and pressedMidStep then
break
end
end
U.wait(2)
game.input.state.start = false
local top = game.stack:top()
U.shot(game, DIR .. "/bug525_2_after_start.png")
check("START held through the landing opened the start menu",
top ~= ow and top ~= nil)
check("the player is not frozen mid-tile (px/py land on a 16px cell)",
ow.player.px % 16 == 0 and ow.player.py % 16 == 0)
-- close it and let the roll continue, proving the pull did not get
-- eaten along with the buffered button
if top ~= ow then
while game.stack:top() ~= ow do
U.tap(game, "b")
U.wait(10)
end
end
local yBeforeResume = ow.player.cellY
U.wait(48)
check("the roll resumes south after closing the menu",
ow.player.cellY > yBeforeResume)
U.shot(game, DIR .. "/bug525_3_resumed.png")
-- park with road left to roll, on the bike, before handing over
U.teleport(game, MAP, START_X, START_Y, "down")
U.wait(10)
game.save.onBike = true
U.log("On the bike on Cycling Road at (" .. START_X .. "," .. START_Y ..
"), rolling south hands-off.")
U.log("Press START right as you see a step land, and again mid-step")
U.log("while releasing before it lands. Right: a START you keep pressed")
U.log("into the landing opens the menu every time, one you let go of")
U.log("mid-step does nothing (same as standing still). #525 was the")
U.log("held case sometimes doing nothing at all, a coin flip on this hill.")
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
@@ -0,0 +1,107 @@
-- Driver: Fighting Dojo Karate Master does not leave his post (#502),
-- the opposite-direction report against the same script #495 fixed
-- (data/scripts/story4.lua dojoMasterGate / M.FIGHTING_DOJO.onStep).
--
-- Before the fix his trainer header used range=4/DOWN sight aggro
-- (src/core/Data.lua seedFightingDojoKarateMaster), so walking up his
-- column from below put him in CheckFightingMapTrainers' generic path:
-- the NPC walks toward the player before the pre-battle text, straight
-- through FIGHTINGDOJO_BLACKBELT3/4's tiles at (5,5)/(5,7) -- the
-- "merges with a pupil" glitch, and it fired regardless of whether the
-- pupils in front of him had been beaten yet ("forces the fight early").
-- The fix sets range=0 and gates him on the single tile at his left
-- (4,3) instead (onStep = dojoMasterGate): he only ever turns to face
-- the player there, never steps.
--
-- POKEPORT_DRIVER=tests/drivers/fighting_dojo_master_bug502_test.lua \
-- POKEPORT_IDENTITY=bug502 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
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 function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function masterIn(ow)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == "FIGHTINGDOJO_KARATE_MASTER" then return n end
end
return nil
end
-- No pupil beaten, master not beaten: the exact state the report says
-- triggered the early merge (walking up before clearing the pupils).
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_BEAT_KARATE_MASTER = nil
game.save.defeatedTrainers = {}
------------------------------------------------------------------
-- Direction 1 (#502): stand in the master's own sight column, below
-- him, with his southern pupils (BLACKBELT3 at (5,5), BLACKBELT4 at
-- (5,7)) still in the way -- exactly the "walk towards Karate Master"
-- report. The old range=4/DOWN header put any of these cells in his
-- sight; his first step toward the player collided with BLACKBELT3's
-- tile. Collision blocks a real walk onto an NPC's cell, so probe by
-- teleport (a scripted approach cannot walk through them either) and
-- watch several fixed steps for the master moving or engaging at all.
------------------------------------------------------------------
local ow, master
local function probe(px, py, label)
U.teleport(game, "FIGHTING_DOJO", px, py, "up")
U.wait(10)
ow = game.stack:top()
master = masterIn(ow)
U.shot(game, DIR .. "/bug502_1_" .. label .. "_before.png")
local moved, engaged = false, false
for _ = 1, 40 do
U.wait(1)
if master.cellX ~= 5 or master.cellY ~= 3 then moved = true end
if getmetatable(game.stack:top()) == TextBox then engaged = true; break end
end
U.shot(game, DIR .. "/bug502_1_" .. label .. "_after.png")
check("Fighting Dojo is loaded (" .. label .. ")", ow.map.id == "FIGHTING_DOJO")
check("Karate Master stayed at (5,3), did not walk toward (" .. label .. ")",
not moved)
check("standing at " .. label .. " started no battle on its own",
not engaged)
return moved or engaged
end
-- (5,4): directly below the master, one tile from BLACKBELT3 (5,5).
-- (5,6): the gap between BLACKBELT3 and BLACKBELT4, still in the old
-- sight range and still boxed in by pupils on both sides.
local bad1 = probe(5, 4, "below_master")
local bad2 = probe(5, 6, "between_pupils")
------------------------------------------------------------------
-- Direction 2 (#495, same script): the ONLY tile that should start his
-- fight is one step left of him. Confirm the gate still works so the
-- fix didn't just make him inert in both directions.
------------------------------------------------------------------
U.teleport(game, "FIGHTING_DOJO", 4, 2, "down")
U.wait(10)
ow = game.stack:top()
master = masterIn(ow)
U.shot(game, DIR .. "/bug502_3_gate_before.png")
U.tap(game, "down")
U.wait(30)
local gateOpened = getmetatable(game.stack:top()) == TextBox
U.shot(game, DIR .. "/bug502_4_gate_after.png")
check("stepping onto (4,3) still starts the Master's challenge", gateOpened)
check("he turned to face the player instead of walking to them",
master.cellX == 5 and master.cellY == 3 and master.facing == "left")
U.log("Compare bug502_1/2: the Master's sprite should sit in the same")
U.log("spot in both, never sliding down onto BLACKBELT3 or BLACKBELT4.")
U.log("bug502_4 should show his pre-battle text box, with him still on")
U.log("(5,3), just turned to face left -- that is the correct trigger,")
U.log("distinct from the column walk that used to glitch him south.")
while true do
coroutine.yield()
end
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
@@ -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
+6 -4
View File
@@ -2414,14 +2414,15 @@ local function sellItem(id)
return sold
end
-- Free up bag slots so `needed` NEW item kinds fit (Bag.CAPACITY is 20
-- slots; a stack of an item already held costs nothing). This is why
-- Free up bag slots so `needed` NEW item kinds fit (20 slots without a
-- capacity mod; a stack of an item already held costs nothing). This is why
-- every restock had been reporting "HYPER_POTION x0": the buy list
-- opened, the quantity was set, the engine said "no room" -- and the run
-- walked into the Mansion with FULL_HEALs but not one HP restore.
local function freeBagSlots(needed, where)
local used = #(G.save.bagOrder or {})
local free = 20 - used
local capacity = require("src.inventory.Bag").capacity(G.data)
local free = capacity - used
for _, id in ipairs(SELLABLE_JUNK) do
if free >= needed then break end
if ((G.save.inventory or {})[id] or 0) > 0 then
@@ -2632,7 +2633,8 @@ function ops.shop(s, where)
end
end
local used = #(G.save.bagOrder or {})
if newKinds > 0 and 20 - used < newKinds then
local capacity = require("src.inventory.Bag").capacity(G.data)
if newKinds > 0 and capacity - used < newKinds then
freeBagSlots(newKinds, where)
end
end
+190
View File
@@ -0,0 +1,190 @@
-- Soft reset from inside a battle (#563): A+B+SELECT+START held together
-- drops back to the title, the way _Joypad's `cp PAD_BUTTONS` (pokered
-- engine/joypad.asm:6) reads the raw pad ahead of the wJoyIgnore masking
-- that a battle turn leans on. Counting and touch-overlay halves are
-- asserted in tests/engine/soft_reset_bug563.lua; this is the ear and eye
-- half -- music cutting to the title theme, unsaved progress gone.
-- POKEPORT_DRIVER=tests/drivers/soft_reset_bug563_test.lua POKEPORT_IDENTITY=bug563 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 Input = require("src.core.Input")
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
local CHORD = { "a", "b", "start", "select" }
-- The driver presses GB buttons by name, so it proves the chord is read
-- but says nothing about which keys reach it. Print the live map for the
-- hand-off instead of hard-coding key names into the notes.
local function keysFor(btn)
local keys = {}
for key, action in pairs(Input.keyBindings or {}) do
if action == btn then keys[#keys + 1] = key end
end
table.sort(keys)
return table.concat(keys, " / ")
end
U.log("#563 soft reset: machine checks")
check("Input:softResetHeld exists", type(Input.softResetHeld) == "function")
check("Input:softResetStep exists", type(Input.softResetStep) == "function")
for _, btn in ipairs(CHORD) do
local keys = keysFor(btn)
check(btn:upper() .. " has a key bound (" .. keys .. ")", keys ~= "")
end
local musicVol = game.save.options and game.save.options.musicVol
if musicVol == 0 then
U.log("musicVol is 0: the battle music cutting to the title theme is the")
U.log("main thing to listen for, so raise it in OPTION before judging")
else
U.log("musicVol", tostring(musicVol),
"-- the battle theme should stop dead and the title theme start")
end
-- ---- a battle to reset out of -----------------------------------------
game.save.party = {
Pokemon.new(game.data, "SQUIRTLE", 30),
Pokemon.new(game.data, "PIDGEOTTO", 28),
}
game.save.player.name = "RED"
-- data/generated/maps.lua ROUTE_1: (5, 5) is open walkable ground. The
-- battle is pushed straight in rather than encountered, so the cell only
-- has to be somewhere the player can legally stand.
local START = { x = 5, y = 5 }
U.teleport(game, "ROUTE_1", START.x, START.y, "down")
U.wait(10)
local ow = game.overworld
if ow and not ow.map:isWalkableCell(START.x, START.y) then
-- a map edit moved the path: take the nearest walkable cell instead
local sx, sy
for r = 1, 6 do
for dy = -r, r do
for dx = -r, r do
if not sx and ow.map:isWalkableCell(START.x + dx, START.y + dy) then
sx, sy = START.x + dx, START.y + dy
end
end
end
if sx then break end
end
if sx then
U.log(("(%d, %d) is blocked, standing on"):format(START.x, START.y), sx, sy)
U.teleport(game, "ROUTE_1", sx, sy, "down")
U.wait(10)
ow = game.overworld
end
end
check("the overworld is up on ROUTE_1", ow ~= nil)
-- something to reset away from: a fight in progress and 2 unsaved mon
local function enterBattle()
local battle = BattleState.newWild(game, "RATTATA", 12)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
for _ = 1, 400 do
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
U.wait(1)
end
return battle
end
local battle = enterBattle()
check("the battle reached the screen", game.stack:top() == battle)
check("battle screenshot reached disk",
U.shot(game, DIR .. "/bug563_battle.png"))
-- ---- hold the chord ----------------------------------------------------
-- A real hold is one press edge and then sustained state, so queue the
-- edge once and only keep `state` alive after that. Re-queueing every
-- frame would feed the battle 40 A presses and run the fight away before
-- the countdown ever finished. Stops the moment the title appears: the
-- driver has no key-up, so a hold left running past the reset would press
-- A on the title's first frame and start a new game by itself.
local function holdChord(frames, extra)
local buttons = { unpack(CHORD) }
if extra then buttons[#buttons + 1] = extra end
for frame = 1, frames do
for _, btn in ipairs(buttons) do
if frame == 1 then table.insert(game.input.pressQueue, btn) end
game.input.state[btn] = true
end
coroutine.yield()
local top = game.stack:top()
if top and top.screenId == "TitleState" then break end
end
for _, btn in ipairs(buttons) do game.input.state[btn] = false end
end
-- Control case first: `cp PAD_BUTTONS` is an equality, so the same four
-- buttons plus a direction is not the combo however long it is held. If
-- this one resets, the port is masking the read instead of comparing it.
holdChord(40, "up")
U.wait(5)
check("four buttons plus UP for 40 frames does NOT reset",
game.stack:top() == battle)
-- 16 polls is the countdown Init seeds hSoftReset with (home/init.asm:81);
-- 60 frames leaves room for it without hiding an off-by-one, which the
-- engine suite pins exactly.
holdChord(60)
U.wait(20)
local top = game.stack:top()
check("the chord dropped the battle for the title",
top ~= nil and top.screenId == "TitleState")
check("the battle state is gone from the stack", top ~= battle)
check("no button is left held into the title", not game.input:isDown("a"))
check("title screenshot reached disk", U.shot(game, DIR .. "/bug563_title.png"))
-- the title must not act on the still-held buttons on its own first frames
U.wait(60)
check("the title is still up a second later, not a menu it auto-picked",
game.stack:top() == top)
-- ---- hand off inside a fresh battle ------------------------------------
-- Handing the pad over needs a party again. A player who did this for
-- real would be picking CONTINUE here and getting whatever was last saved,
-- which is the point of the reset -- this is only the driver restocking.
game.save.party = {
Pokemon.new(game.data, "SQUIRTLE", 30),
Pokemon.new(game.data, "PIDGEOTTO", 28),
}
U.teleport(game, "ROUTE_1", START.x, START.y, "down")
U.wait(10)
local again = enterBattle()
check("a second battle is up for the manual attempt", game.stack:top() == again)
U.log("The driver already did this once: /tmp/shots/bug563_battle.png is the")
U.log("fight it was in, bug563_title.png is where it landed. There is a")
U.log("fresh RATTATA battle on screen now for you to repeat it by hand.")
U.log("Hold " .. keysFor("a") .. " (A) + " .. keysFor("b") .. " (B) + "
.. keysFor("start") .. " (START) + " .. keysFor("select") .. " (SELECT)")
U.log("together for about a third of a second. The battle theme should cut")
U.log("out and the title come back with its own music; CONTINUE from there")
U.log("gives you the last save, so the fight and anything after it is gone.")
U.log("Add any arrow key to the four and nothing should happen at all. A")
U.log("quick tap of all four in passing must not reset either, and letting")
U.log("one go a beat early must start the count over rather than resume it.")
U.log("Control case if it does nothing: QUIT in the START menu runs the same")
U.log("return-to-title, so if QUIT works the pad read is what to look at.")
U.log("For the on-screen overlay, re-run with POKEPORT_TOUCH=1: the mouse is")
U.log("one finger there, so the combo cannot be reached at all on desktop.")
U.log(("checks: %d passed, %d failed"):format(pass, fail))
while true do
coroutine.yield()
end
end
@@ -0,0 +1,223 @@
-- Manual check of the two trainer-battle text boxes, #565 and #566.
-- #565 _TrainerAboutToUseText (data/text/text_2.asm:911-921): the SHIFT
-- offer is one box with a `cont`, so the nick scrolls in UNDER "about to
-- use" instead of landing alone on a fresh page.
-- #566 TrainerEndBattleText (home/trainers.asm:381-386) prints
-- _TrainerNameText -- the name then ": " -- ahead of the loss line.
-- The string half is asserted in tests/engine/trainer_shift_prompt_bug565.lua
-- and tests/engine/trainer_loss_line_bug566.lua; this is the look of it.
-- POKEPORT_DRIVER=tests/drivers/trainer_battle_text_bug565_bug566_test.lua POKEPORT_IDENTITY=bug565 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Font = require("src.render.Font")
local ChoiceBox = require("src.ui.ChoiceBox")
-- pokered data/maps/objects/Route3.asm: ROUTE3_YOUNGSTER2 is object 3, at
-- (14, 4), STAY, facing DOWN, OPP_YOUNGSTER party 1 (RATTATA + EKANS). Its
-- TrainerHeader gives sight range 3, so the cone is (14,5)..(14,7); y = 7 is
-- the ledge row and is solid, so the walk-in is east-to-west along y = 6.
local MAP, MAP_LABEL = "ROUTE_3", "Route3"
local NPC_NAME = "ROUTE3_YOUNGSTER2"
local OBJ_INDEX = 3
local START = { x = 16, y = 6, facing = "left" }
local fails = 0
local function check(label, ok)
if not ok then fails = fails + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- Everything the two boxes are assembled from. A renamed text label, an
-- opponent whose party shrank to one mon or a species constant that no
-- longer resolves all show up on screen as exactly the silence the bugs
-- did, so name them here before anyone squints at a screenshot.
local header = game.data:trainerHeader(MAP_LABEL, OBJ_INDEX)
check("Route3 object " .. OBJ_INDEX .. " has a trainer header", header ~= nil)
local lossText = header and header.won and game.data.text[header.won]
check("its EndBattleText resolves to a string",
type(lossText) == "string" and lossText ~= "")
local trainer = game.data.trainers.OPP_YOUNGSTER
check("OPP_YOUNGSTER is a known trainer class", trainer ~= nil)
local party = trainer and trainer.parties[1]
check("its party 1 has a reserve to send out (SHIFT needs one)",
party ~= nil and #party >= 2)
local secondName
if party and party[2] then
local species = game.data.pokemon[party[2].species]
secondName = species and species.name
end
check("the reserve's species resolves to a name", secondName ~= nil)
local TAG = trainer and trainer.name
local firstLine = TAG and lossText
and (TAG .. ": " .. lossText:match("^[^\n\f\v]*"))
if firstLine then
-- the tag is prose the box has to hold: 18 columns, TextBox MAX_COLS
local spans = Font.split(firstLine)
check("the tagged first line fits the 18-column box (" .. #spans .. " cols)",
Font.spansFitting(spans, 18 * 8) >= #spans)
U.log("loss box should read:",
((TAG .. ": " .. lossText):gsub("[\n\v]", " / "):gsub("\f", " || ")))
end
-- SHIFT only offers the switch when the player holds more than one party
-- slot and the active mon is alive (core.asm:1366-1443)
game.save.player.name = "RED"
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 40),
Pokemon.new(game.data, "BLASTOISE", 40),
}
game.save.party[1].moves[1] = { id = "SLASH", pp = 20 }
game.save.options = game.save.options or {}
game.save.options.battleStyle = "shift"
check("two party slots and SHIFT style are set",
#game.save.party > 1 and game.save.options.battleStyle == "shift")
check("the window is open and drawing",
love.window and love.window.isOpen and love.window.isOpen())
if fails > 0 then
U.log(fails .. " preflight check(s) failed; the boxes below cannot be trusted.")
end
U.teleport(game, MAP, START.x, START.y, START.facing)
U.wait(15)
local function findNpc()
for _, n in ipairs((game.overworld and game.overworld.npcs) or {}) do
if n.def and n.def.name == NPC_NAME then return n end
end
end
local npc = findNpc()
check("the YOUNGSTER is on the map", npc ~= nil)
-- A map edit that moves him invalidates the hand-derived walk-in, so fall
-- back to standing next to wherever he ended up and talking: engageTrainer
-- is the same call the line-of-sight approach makes.
local talkedTo = false
if npc and (npc.cellX ~= 14 or npc.cellY ~= 4) then
local ow = game.overworld
-- {dx, dy, facing to look back at him}
for _, side in ipairs({ { 0, 1, "up" }, { 0, -1, "down" },
{ 1, 0, "left" }, { -1, 0, "right" } }) do
local cx, cy = npc.cellX + side[1], npc.cellY + side[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log("he has moved to", npc.cellX, npc.cellY, "- standing at", cx, cy)
U.teleport(game, MAP, cx, cy, side[3])
U.wait(10)
talkedTo = true
U.tap(game, "a")
break
end
end
end
local function battleNow()
local top = game.stack:top()
if type(top) == "table" and top.kind == "trainer" then return top end
end
-- walk west into the sight cone; two cells, plus slack for the "!" bubble
if not talkedTo then
for _ = 1, 6 do
if game.overworld and game.overworld.engaging then break end
U.hold(game, "left", 18)
U.wait(4)
end
end
check("the trainer engaged",
(game.overworld and game.overworld.engaging) or battleNow() ~= nil
or talkedTo)
-- Play it out. Nothing here is fast-forwarded: the taps are A presses at
-- the same cadence a player makes them, and the loop stops on the row
-- under test before it can press the box away.
local function currentRow()
local b = battleNow()
local row = b and b.current
return b, row and row.text
end
local function pump(pred, maxFrames)
local cool = 0
for _ = 1, maxFrames or 4000 do
if pred() then return true end
if cool > 0 then
cool = cool - 1
U.wait(1)
else
U.tap(game, "a")
local b = battleNow()
cool = (b and (b.phase == "menu" or b.phase == "moveSelect")) and 8 or 5
end
end
return pred()
end
local ok = pump(function()
local _, text = currentRow()
return text ~= nil and text:find("about to use", 1, true) ~= nil
end)
check("the SHIFT offer box came up after the first KO", ok)
-- Hold at the `cont`: the box has typed "X is" / "about to use" and is
-- blinking the arrow, waiting for A before it scrolls (ContText).
pump(function()
local b = currentRow()
return b and b.msgWaiting
end, 400)
U.shot(game, SHOT_DIR .. "/bug565_1_cont_wait.png")
local b = battleNow()
if b and b.current then
U.log("offer box holds:", (b.current.text:gsub("\n", " / "):gsub("\v", " CONT ")))
end
U.tap(game, "a")
-- and after the scroll: "about to use" on top, the nick beneath it
pump(function()
local bb = battleNow()
return bb and (bb.charIndex or 0) >= (bb.total or 1) and not bb.msgWaiting
end, 400)
U.shot(game, SHOT_DIR .. "/bug565_2_scrolled.png")
-- decline the switch (B is NO, ChoiceBox) and finish the fight
pump(function() return getmetatable(game.stack:top()) == ChoiceBox end, 600)
U.wait(10)
U.tap(game, "b")
U.wait(10)
local tagged = TAG and (TAG .. ": ") or "YOUNGSTER: "
ok = pump(function()
local _, text = currentRow()
return text ~= nil and text:sub(1, #tagged) == tagged
end)
check("the loss line came up tagged with the trainer class", ok)
pump(function()
local bb = battleNow()
return bb and bb.msgPrompt
end, 400)
U.shot(game, SHOT_DIR .. "/bug566_loss_line.png")
local lb = battleNow()
if lb and lb.current then
U.log("loss box holds:", (lb.current.text:gsub("\n", " / ")))
end
U.log("Shots are in " .. SHOT_DIR .. ".")
U.log("bug565_1 should read \"YOUNGSTER is\" / \"about to use\" with the")
U.log("blinking arrow; bug565_2 the same box after the scroll, \"about to")
U.log("use\" over \"EKANS!\". The near miss is a box whose two lines are")
U.log("\"about to use\" and nothing, or a page holding only \"EKANS!\".")
U.log("bug566 should read \"YOUNGSTER: I don't\" / \"believe it!\" -- the")
U.log("colon and the class, not a bare \"I don't believe it!\".")
U.log("The prize line follows it; the tag never repeats on that one.")
U.log("A is yours now: press through the money line back to the route.")
U.log("ROUTE3_YOUNGSTER1 at (10,6) has three mons, so the SHIFT box comes")
U.log("up twice. ROUTE3_YOUNGSTER4 at (22,9) is the control: one mon, so")
U.log("he must show the tagged loss line and no SHIFT offer at all.")
while true do
coroutine.yield()
end
end
+257
View File
@@ -0,0 +1,257 @@
-- A tap on the d-pad turns the player in place; only a held direction steps
-- (#415). home/overworld.asm .handleDirectionButtonPress only reaches the
-- turn while wCheckFor180DegreeTurn is set, and .noDirectionButtonsPressed
-- (the poll that finds nothing held) is the one place that sets it, so the
-- delay is paid once per press and never at a corner. This scripts the
-- window it can measure and then hands the pad over, because no scripted
-- press has a human thumb's length.
-- POKEPORT_DRIVER=tests/drivers/turn_in_place_bug415_test.lua \
-- POKEPORT_IDENTITY=bug415 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local TextBox = require("src.render.TextBox")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- pokered data/maps/objects/PalletTown.asm: PALLETTOWN_GIRL is the one
-- WALK/ANY_DIR object on the town's open west side, at cell (3, 8) with
-- floor on three sides -- the ordinary "someone standing next to you"
-- shape the report is about. object_event coords are already walk-grid
-- cells (src/world/NPC.lua), so they carry over unchanged.
local MAP = "PALLET_TOWN"
local MAP_LABEL = "PalletTown"
local NPC_NAME = "PALLETTOWN_GIRL"
local NPC_TEXT = "TEXT_PALLETTOWN_GIRL"
local GIRL = { x = 3, y = 8 }
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- Held-direction primitives that never lift the d-pad. U.hold releases at
-- the end of every call, which is exactly what the corner case below must
-- not do: a release re-arms the turn and hides the thing being measured.
local function press(btn)
table.insert(game.input.pressQueue, btn)
game.input.state[btn] = true
end
local function release(btn)
game.input.state[btn] = false
end
-- an unresolved text label, a renamed object and a press that never landed
-- all look like the same nothing-happened the bug looked like
local girlText = game.data:resolveText(MAP_LABEL, NPC_TEXT)
check(NPC_TEXT .. " resolves to a string",
type(girlText) == "string" and girlText ~= "")
U.teleport(game, MAP, GIRL.x, GIRL.y - 1, "up")
U.wait(10)
local ow = game.overworld
check("Pallet Town is loaded", ow ~= nil and ow.map.id == MAP)
local function girlIn(state)
for _, n in ipairs(state.npcs or {}) do
if n.def and n.def.name == NPC_NAME then return n end
end
return nil
end
-- She is a WALK object, so pin her before deriving anything from her cell:
-- NPC:update returns early on frozen, and snapping the pixels back undoes
-- any step already in flight when the driver got here.
local function pin(state)
local npc = girlIn(state)
if npc then
npc.frozen = true
npc.moving = false
npc.cellX, npc.cellY = GIRL.x, GIRL.y
npc.px, npc.py = npc.cellX * 16, npc.cellY * 16
npc.facing = "down"
end
return npc
end
local girl = pin(ow)
check("the girl object loaded and is pinned at (3, 8)",
girl ~= nil and girl.cellX == GIRL.x and girl.cellY == GIRL.y)
-- Stand on her north side facing away, so the first tap has to turn. If a
-- map edit takes that cell, fall back to any free walkable neighbour and
-- face away from her there; {dx, dy, away, toward} is the offset from her
-- to the stand cell plus the two directions that matter from it.
local stand = { x = GIRL.x, y = GIRL.y - 1, away = "up", toward = "down" }
local function free(cx, cy)
return ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy)
end
if girl and not free(stand.x, stand.y) then
for _, s in ipairs({ { 0, 1, "down", "up" }, { -1, 0, "left", "right" },
{ 1, 0, "right", "left" } }) do
local cx, cy = girl.cellX + s[1], girl.cellY + s[2]
if free(cx, cy) then
U.log(("(%d, %d) is not free; standing at"):format(stand.x, stand.y),
cx, cy, "facing", s[3])
stand = { x = cx, y = cy, away = s[3], toward = s[4] }
break
end
end
end
U.teleport(game, MAP, stand.x, stand.y, stand.away)
U.wait(10)
ow = game.overworld
pin(ow)
local p = ow.player
check("standing beside the girl, facing away from her",
p.cellX == stand.x and p.cellY == stand.y and p.facing == stand.away)
-- What the fix moved, read off the running game rather than repeated from
-- a comment: turnFrames is the dataset constant a mod can restamp
-- (src/world/FieldDefaults.lua) and the touch figure comes back out of
-- Player:turnWindow with a live overlay source. turnWindow is the fix's
-- own entry point, so a build without it falls back to the one window it
-- had for every source and the two checks below say so.
local function window()
return p.turnWindow and p:turnWindow() or p.turnFrames
end
local padWindow = window()
game.input:overlayPressed(p.facing)
game.input:step()
local touchWindow = window()
game.input:overlayReleased(p.facing)
game.input:step()
check("the pad turn window is 4 fixed steps, up from 2", padWindow == 4)
check("the on-screen d-pad gets 8", touchWindow == 8)
U.log(("turn window: %d fixed steps on keyboard and pad (%d ms), %d on the ")
:format(padWindow, math.floor(padWindow * 1000 / 60), touchWindow)
.. ("on-screen d-pad (%d ms); hold past it and the step commits.")
:format(math.floor(touchWindow * 1000 / 60)))
-- A press no longer than the window turns and stops there.
for _ = 1, padWindow do
press(stand.toward)
coroutine.yield()
end
release(stand.toward)
U.wait(6)
check(("a %d-step press turned toward the girl"):format(padWindow),
p.facing == stand.toward)
check("and left the feet on the same tile",
p.cellX == stand.x and p.cellY == stand.y)
-- The same press held well past the window does step. Sideways, because
-- the girl blocks the step toward her and would mask it.
local openDir, openBack
for _, s in ipairs({ { "left", "right", -1, 0 }, { "right", "left", 1, 0 },
{ "up", "down", 0, -1 }, { "down", "up", 0, 1 } }) do
if s[1] ~= stand.toward and free(stand.x + s[3], stand.y + s[4]) then
openDir, openBack = s[1], s[2]
break
end
end
if openDir then
U.hold(game, openDir, 40)
U.wait(10)
check(("a 40-step hold of %s walks off the tile"):format(openDir),
p.cellX ~= stand.x or p.cellY ~= stand.y)
-- The corner: swap direction without ever lifting the d-pad. Walk back
-- on openBack first and break on the landing poll, so the pad is still
-- down and the turn is still spent when the swap arrives.
local walked = false
for _ = 1, 240 do
press(openBack)
coroutine.yield()
if p.moving then
walked = true
elseif walked then
break
end
end
check(("the return hold of %s is walking"):format(openBack), walked)
-- Nothing has re-armed wCheckFor180DegreeTurn, so the new facing steps
-- on the poll it arrives on with no turn delay. Pre-fix, every corner
-- paid the window again. Perpendicular and walkable from wherever the
-- return hold landed, so a blocked cell can't read as a stall.
local DELTA = { up = { 0, -1 }, down = { 0, 1 },
left = { -1, 0 }, right = { 1, 0 } }
local swap
for _, d in ipairs({ "up", "down", "left", "right" }) do
local dd = DELTA[d]
if d ~= openDir and d ~= openBack
and free(p.cellX + dd[1], p.cellY + dd[2]) then
swap = d
break
end
end
if swap then
local wasFacing = p.facing
release(openBack)
press(swap) -- same poll, so the d-pad never reads empty
coroutine.yield()
U.log(("corner: swapped %s for %s without a release, facing went %s to %s")
:format(openBack, swap, wasFacing, p.facing))
check("the swapped direction steps on the poll it arrives on",
p.facing == swap and p.moving and p.turnTimer == 0)
release(swap)
else
U.log("no perpendicular open cell here; skipped the corner check")
release(openBack)
end
for _ = 1, 200 do
if not p.moving then break end
coroutine.yield()
end
else
U.log("no open cell beside the stand tile; skipped the hold and corner")
end
-- Back beside her, facing her, and talk: a silent A here would look exactly
-- like a facing that missed.
U.teleport(game, MAP, stand.x, stand.y, stand.toward)
U.wait(10)
ow = game.overworld
p = ow.player
girl = pin(ow)
local fx, fy = p:facingCell()
check("the facing cell is the girl's",
girl ~= nil and ow:npcAtCell(fx, fy) == girl)
U.tap(game, "a")
U.wait(30)
local top = game.stack:top()
local isBox = getmetatable(top) == TextBox
check("A opened her text box", isBox)
if isBox then
local shown = {}
for _, page in ipairs(top.pages or {}) do
for _, line in ipairs(page) do shown[#shown + 1] = line end
end
U.log("box reads:", table.concat(shown, " / "))
end
if U.shot(game, SHOT_DIR .. "/bug415_turn_in_place.png") then
U.log("captured", SHOT_DIR .. "/bug415_turn_in_place.png",
"so the window did render; a blank screen later is not the driver")
end
U.tap(game, "b")
U.wait(20)
U.log("You are on the girl's north side in Pallet Town, facing her, with")
U.log("open ground north, east and west. Tap a direction: Red should")
U.log("pivot on the spot and stay on his tile. Hold the same direction")
U.log("and he walks, with no extra pause before the first step. Then walk")
U.log("a lap and change direction without letting go; corners should stay")
U.log("smooth, and a stall there is the half of #415 that is not the tap.")
U.log("The near miss to watch for is a tap that turns AND slides one tile,")
U.log("which still reads as a turn unless you watch Red's feet against the")
U.log("fence. The girl is the control: she blocks the step, so tapping")
U.log("toward her looked fine even while this was broken. That is the")
U.log("Yellow complaint in the report -- the follower Pikachu does not")
U.log("block (src/world/PikachuFollower.lua), so a tap meant to turn and")
U.log("talk to it walked onto its cell and straight past it instead.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,390 @@
-- Manual check that BATTLE LAYOUT = WIDE draws move animations intact (#562).
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the shake
-- predefs displace is the whole screen; the wide composition moved only the
-- two pic regions, which slid the monsters across a nailed-down HUD and text
-- box. TAIL WHIP is the reported case (wAnimationType 6,
-- AnimationShakeScreenHorizontallySlow b=3, animations.asm:526). Composition
-- half: tests/engine/wide_battle_shake_bug562.lua. Never under POKEPORT_SPEED.
-- POKEPORT_DRIVER=tests/drivers/wide_move_anim_bug562_test.lua POKEPORT_IDENTITY=bug562 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 WideBattle = require("src.battle.WideBattle")
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
-- One move per shake family the wide composition has to carry. `hitX` is
-- the applying-attack amplitude PlayApplyingAttackAnimation plays after the
-- move (AnimationTypePointerTable, animations.asm:475); `animX` is a shake
-- the move's own subanimation runs. wAnimationType 4, 5 and 6 are the
-- player's turn -- the vertical 1 and the wide 2 / 3 are the foe's, and the
-- foe's rows are reported below rather than driven.
-- TAIL WHIP type 6, AnimationShakeScreenHorizontallySlow b=3 (silent;
-- every player status move takes this path)
-- BUBBLEBEAM type 5, fast horizontal b=2, with the damage thud
-- EARTHQUAKE SE_SHAKE_SCREEN inside its own animation (fastShakeProg 8),
-- shared with FISSURE and SEISMIC TOSS
-- WITHDRAW the control: a stat-UP effect leaves wAnimationType at 0
-- (effects.asm, PlayCurrentMoveAnimation), so nothing moves.
-- It also fills the fourth grid slot, which the 2x2 needs.
local MOVES = {
{ slot = 1, id = "TAIL_WHIP", type = 6, hitX = 3 },
{ slot = 2, id = "BUBBLEBEAM", type = 5, hitX = 2 },
{ slot = 3, id = "EARTHQUAKE", type = 4, animX = 8 },
{ slot = 4, id = "WITHDRAW", type = 0, hitX = 0, hitY = 0 },
}
-- ---- machine checks ----------------------------------------------------
-- A move missing from the table, a layout that never switched and a battle
-- that never reached WIDE all look like "the animation is fine" from a chair.
U.log("#562 wide-layout move animations: machine checks")
for _, m in ipairs(MOVES) do
check(m.id .. " is in the move table", game.data.moves[m.id] ~= nil)
end
local anims = game.data.battle_anims and game.data.battle_anims.moveAnims
check("battle_anims carries TAIL WHIP's own animation",
anims ~= nil and anims.TAIL_WHIP ~= nil)
check("...and EARTHQUAKE's, which carries SE_SHAKE_SCREEN itself",
anims ~= nil and anims.EARTHQUAKE ~= nil)
check("animations are on in OPTIONS (the shakes are gated on them)",
game.save.options.animations ~= false)
game.save.options.battleLayout = "wide"
check("BATTLE LAYOUT is WIDE", game.save.options.battleLayout == "wide")
local vol = game.save.options and game.save.options.sfxVol
U.log("sfxVol", tostring(vol),
vol == 0 and "-- SILENT, raise it in OPTION before judging the sound"
or "-- BUBBLEBEAM and EARTHQUAKE carry the damage thud; TAIL WHIP is silent")
-- ---- fixture -----------------------------------------------------------
local squirtle = Pokemon.new(game.data, "SQUIRTLE", 40)
squirtle.moves = {}
for _, m in ipairs(MOVES) do
squirtle.moves[m.slot] = { id = m.id, pp = game.data.moves[m.id].pp }
end
game.save.party = { squirtle }
game.save.player.name = "RED"
-- data/generated/maps.lua ROUTE_1: (5, 5) is open walkable ground; the
-- battle is pushed straight in rather than encountered
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(10)
local ow = game.overworld
if ow and ow.map and not ow.map:isWalkableCell(5, 5) then
-- a map edit moved the path: take the nearest walkable cell instead
local sx, sy
for r = 1, 6 do
for dy = -r, r do
for dx = -r, r do
if not sx and ow.map:isWalkableCell(5 + dx, 5 + dy) then
sx, sy = 5 + dx, 5 + dy
end
end
end
if sx then break end
end
if sx then
U.log("(5, 5) is blocked, standing on", sx, sy)
U.teleport(game, "ROUTE_1", sx, sy, "down")
U.wait(10)
ow = game.overworld
end
end
check("the overworld is up on ROUTE_1", ow ~= nil)
-- a Normal-type foe: PIDGEY is Flying and would sit out EARTHQUAKE entirely
local battle = BattleState.newWild(game, "RATTATA", 20)
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)
check("the battle is drawing the wide surface", battle:wideLayout() == true)
local uw = battle:uiSize()
check(("the surface is %dpx wide (got %s)"):format(WideBattle.WIDTH, tostring(uw)),
uw == WideBattle.WIDTH)
-- a foe that survives the whole run: every family needs its own turn, and
-- EARTHQUAKE off a level 40 SQUIRTLE takes a large bite
battle.enemy.mon.stats.hp = 900
battle.enemy.mon.hp = 900
battle.enemy.shownHP = 900
-- WHIRLWIND ends a wild battle outright ("run"), taking the untested
-- families with it; curMoves aliases mon.moves, so one pass covers both
for i = #battle.enemy.mon.moves, 1, -1 do
local id = battle.enemy.mon.moves[i].id
if id == "WHIRLWIND" or id == "ROAR" or id == "TELEPORT" then
table.remove(battle.enemy.mon.moves, i)
end
end
-- ---- sample the offsets the composition is fed -------------------------
-- One row per performMove, EITHER side: a row that stayed open across the
-- foe's reply would credit the player's move with the foe's shake. The
-- split at the first applyHitFx separates a shake the move's own animation
-- ran (SE_SHAKE_SCREEN) from the applying-attack one that follows it.
local rows, cur = {}, nil
local realPerform = battle.performMove
battle.performMove = function(self, user, target, moveInst, ...)
cur = { move = moveInst and moveInst.id, isPlayer = user.isPlayer,
animX = 0, animY = 0, hitX = 0, hitY = 0, hitFrames = 0 }
rows[#rows + 1] = cur
return realPerform(self, user, target, moveInst, ...)
end
local realFx = battle.applyHitFx
battle.applyHitFx = function(self, hit)
if cur and cur.animType == nil then cur.animType = hit.animType or 0 end
return realFx(self, hit)
end
local function sample()
local fx = battle.fx
if not (cur and fx) then return end
local dx, dy = math.abs(fx.shakeX or 0), math.abs(fx.shakeY or 0)
if cur.animType == nil then
cur.animX, cur.animY = math.max(cur.animX, dx), math.max(cur.animY, dy)
return
end
if dx > 0 or dy > 0 then cur.hitFrames = cur.hitFrames + 1 end
cur.hitX, cur.hitY = math.max(cur.hitX, dx), math.max(cur.hitY, dy)
end
-- has this row finished its applying-attack effect? Type 4 runs a pic
-- blink and no screen program at all, so it is done as soon as it fires.
local function settled(row)
return row ~= nil and row.animType ~= nil
and (battle.fx == nil or battle.fx.shakeProg == nil)
end
-- step n frames sampling every one, pressing A every `mash` frames but only
-- while a box is up: an A on the FIGHT menu starts a turn behind our back
local function pump(n, mash, stop)
for i = 1, n do
if mash and i % mash == 0 and battle.phase == "messages" then
table.insert(game.input.pressQueue, "a")
end
U.wait(1)
game.input.state.a = false
sample()
if stop and stop() then return end
end
end
local function toMenu()
pump(1500, 6, function() return battle.phase == "menu" and #battle.queue == 0 end)
return battle.phase == "menu"
end
-- The wide move list is a 2x2 grid, and a press that would land on an
-- absent fourth move holds the cursor still (WideBattle.moveGridIndex), so
-- the route is searched through the engine's own navigation rather than
-- assumed: walking the column never reaches slots 2 and 4, and walking the
-- row can dead-end on a three-move party.
local DIRS = { "up", "down", "left", "right" }
local function routeTo(from, slot, count)
local seen, queue = { [from] = true }, { { at = from, path = {} } }
while #queue > 0 do
local node = table.remove(queue, 1)
if node.at == slot then return node.path end
for _, dir in ipairs(DIRS) do
local nxt = WideBattle.moveGridIndex(node.at, count, dir)
if nxt and not seen[nxt] then
seen[nxt] = true
local path = { unpack(node.path) }
path[#path + 1] = dir
queue[#queue + 1] = { at = nxt, path = path }
end
end
end
return nil
end
-- FIGHT is slot 1 of the wide command grid; A opens moveSelect. A press on
-- a frame the battle is not reading input is simply lost, so retry.
local function useMove(slot)
for _ = 1, 80 do
if battle.phase == "moveSelect" then break end
if battle.phase == "menu" then
if battle.menuIndex ~= 1 then
U.tap(game, battle.menuIndex > 2 and "up" or "left")
else
U.tap(game, "a")
end
else
U.tap(game, "a")
end
U.wait(4)
end
if battle.phase ~= "moveSelect" then return false end
-- recompute the route every press: a press that lands on a frame the
-- battle is not reading is lost, and the cursor simply did not move
local count = #(battle.player.curMoves or {})
for _ = 1, 30 do
if battle.moveIndex == slot then break end
local path = routeTo(battle.moveIndex, slot, count)
if not path or #path == 0 then break end
U.tap(game, path[1])
U.wait(3)
end
if battle.moveIndex ~= slot then return false end
for _ = 1, 20 do
if battle.phase ~= "moveSelect" then return true end
U.tap(game, "a")
U.wait(3)
end
return false
end
local function lastPlayerRow(id)
for i = #rows, 1, -1 do
if rows[i].move == id and rows[i].isPlayer then return rows[i] end
end
return nil
end
local SHOTS = { TAIL_WHIP = "bug562_wide_tailwhip.png",
BUBBLEBEAM = "bug562_wide_bubblebeam.png",
EARTHQUAKE = "bug562_wide_earthquake.png" }
-- the foe's vertical drop (type 1) plays on ITS turn, so it is taken
-- whenever one goes past and chased down after the table below if not
local vertShot = false
check("the battle reached its FIGHT menu", toMenu())
for _, m in ipairs(MOVES) do
local shotAt, sent, r
-- BUBBLEBEAM whiffs 1 in 6 and the foe's TAIL WHIP stacks accuracy drops,
-- so give each move a few turns rather than reading a miss (which plays no
-- applying animation at all) as a regression; hand the accuracy back first
for _ = 1, 8 do
battle.player.stages.accuracy = 0
sent = useMove(m.slot)
if not sent then
U.log((" could not reach slot %d for %s (phase %s, move index %s, result %s)")
:format(m.slot, m.id, tostring(battle.phase),
tostring(battle.moveIndex), tostring(battle.result)))
break
end
pump(1500, 8, function()
local fx = battle.fx
local row = lastPlayerRow(m.id)
if not shotAt and row == cur and SHOTS[m.id] and fx
and (fx.shakeX or 0) ~= 0 then
shotAt = U.shot(game, DIR .. "/" .. SHOTS[m.id])
end
if not vertShot and fx and (fx.shakeY or 0) ~= 0
and cur and not cur.isPlayer then
vertShot = U.shot(game, DIR .. "/bug562_wide_foe_vertical.png")
end
if row and battle.phase == "menu" and #battle.queue == 0 then return true end
return settled(row)
end)
toMenu()
r = lastPlayerRow(m.id)
-- a type-0 move never queues an applying-attack row, so one clean turn
-- is all it owes
if r and (r.animType ~= nil or m.type == 0) then break end
U.log(" " .. m.id .. " did not connect that turn; using it again")
end
check("chose " .. m.id .. " from the wide move grid", sent)
if sent and check(m.id .. " was performed", r ~= nil) then
U.log((" %s: type %s, own animation %dpx across / %dpx down, applying "
.. "%dpx across / %dpx down over %d frames")
:format(m.id, tostring(r.animType), r.animX, r.animY,
r.hitX, r.hitY, r.hitFrames))
check(("%s is wAnimationType %d (got %d)")
:format(m.id, m.type, r.animType or 0),
(r.animType or 0) == m.type)
if m.hitX then
check(("%s shakes the screen %dpx sideways (got %d)")
:format(m.id, m.hitX, r.hitX), r.hitX == m.hitX)
end
if m.hitY then
check(("%s leaves the vertical alone (got %d)"):format(m.id, r.hitY),
r.hitY == m.hitY)
end
if m.animX then
check(("%s's own animation shakes %dpx sideways (got %d)")
:format(m.id, m.animX, r.animX), r.animX == m.animX)
end
if SHOTS[m.id] then
check("captured " .. m.id .. " mid-shake", shotAt == true)
end
end
end
-- The vertical drop (type 1) only plays on the foe's turn, so it is taken by
-- spending harmless WITHDRAW turns until the RATTATA picks a plain damaging
-- move. Which move it picks is an AI roll, so a run where it never does is
-- a thin run, not a regression.
local function foeTypeOne()
for _, row in ipairs(rows) do
if not row.isPlayer and row.animType == 1 then return true end
end
return false
end
for _ = 1, 6 do
if vertShot then break end
if not useMove(4) then break end
pump(1200, 8, function()
local fx = battle.fx
if not vertShot and fx and (fx.shakeY or 0) ~= 0
and cur and not cur.isPlayer then
vertShot = U.shot(game, DIR .. "/bug562_wide_foe_vertical.png")
end
return battle.phase == "menu" and #battle.queue == 0
end)
toMenu()
end
local foe = {}
for _, row in ipairs(rows) do
if not row.isPlayer and row.animType then
foe[#foe + 1] = ("%s type %s (%dpx across, %dpx down)")
:format(tostring(row.move), tostring(row.animType),
math.max(row.animX, row.hitX),
math.max(row.animY, row.hitY))
end
end
U.log("the RATTATA's rows: " .. (#foe > 0 and table.concat(foe, "; ") or "none"))
if vertShot then
U.log("captured the foe's 8px vertical drop as well")
elseif foeTypeOne() then
check("the foe's vertical drop was captured", false)
else
U.log("the RATTATA never used a plain damaging move in these turns, so")
U.log("there was no vertical drop to capture; watch for one by hand")
end
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
-- ---- hand off ----------------------------------------------------------
U.log("The pad is yours at the wide FIGHT menu, all four panels on screen.")
U.log("Slot 1 TAIL WHIP creeps the WHOLE screen 1px at a time out to 3px and")
U.log("back, twice, in silence: foe, both status panels and the message box")
U.log("move as one sheet. Slot 2 BUBBLEBEAM snaps it 2px across with the")
U.log("damage thud, slot 3 EARTHQUAKE shakes it 8px from inside its own")
U.log("animation. Let the RATTATA hit back for the vertical one: a plain")
U.log("TACKLE or QUICK ATTACK drops the same sheet 8px. The near miss to")
U.log("watch for is the one #562 reported: the monsters sliding while the HUD")
U.log("panels and the text box stay nailed down, or a pic sheared off at the")
U.log("seam between the two side windows on the way.")
U.log("Slot 4 WITHDRAW is the control: it moves nothing at all, on purpose.")
U.log("So are the animations' own sprites (the bubbles, the EARTHQUAKE rocks)")
U.log("-- those are OAM and correctly do NOT ride the shake, same as in OG.")
U.log("Flip OPTION -> BATTLE LAYOUT to OG and repeat: it should look the same.")
U.log("Screenshots: " .. DIR .. "/bug562_*.png")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,99 @@
-- Manual check that Yellow's bank $1f music headers, wave table and cry
-- data read from the right ROM addresses (#522). Before the fix every
-- header past Music_YellowIntro (a 3-channel header where Red's
-- Music_IntroBattle was 4) was read 3 bytes off, so Viridian Forest's
-- Music_Dungeon2 lost 3 of its 4 channels and its tempo command; every
-- song's channel 3 sampled engine code instead of a wave table.
-- POKEPORT_DRIVER=tests/drivers/yellow_audio_bug522_test.lua POKEPORT_IDENTITY=bug522 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local ChipSynth = require("src.core.ChipSynth")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local audio = game.data.audio or {}
local header = audio.musicHeaders and audio.musicHeaders.Music_Dungeon2
-- pokeyellow.sym: Music_Dungeon2 = 1f:42a9 (bank 31, address 17065)
check("Music_Dungeon2 header reads pokeyellow.sym's address",
header and header.address == 17065 and header.bank == 31)
-- pokeyellow.sym: CryData = 0e:5462 (bank 14, address 21602)
local cry = audio.cryData
check("cryData reads pokeyellow.sym's CryData",
cry and cry.address == 21602 and cry.bank == 14)
-- pokeyellow only has one wave table, Audio1_WavePointers.wave0 = 02:5a26
local waves = audio.waveBanks or {}
local waveOk = true
for _, engine in ipairs({ "1", "2", "3" }) do
local w = waves[engine]
if not (w and w.address == 23078 and w.bank == 2) then waveOk = false end
end
check("all three waveBanks point at Audio1_WavePointers.wave0", waveOk)
-- Rebuild the engine exactly as ChipAudio does and count the channels a
-- misread header would drop to 1 (see headerChannels, ChipSynth.lua).
local engine, engErr
if header then
local ok
ok, engine = pcall(ChipSynth.newEngine, game.data, header)
if not ok then engErr = engine; engine = nil end
end
check("Music_Dungeon2 engine builds" .. (engErr and (": " .. tostring(engErr)) or ""),
engine ~= nil)
check("Music_Dungeon2 has all 4 channels, not the misread header's 1",
engine and #engine.channels == 4)
-- Ch1 opens with the tempo command (dungeon2.asm "Music_Dungeon2_Ch1::
-- tempo 144"); a header pointed at the wrong row drops it and the engine
-- is left at the 0x100 default, ~1.78x slower.
if engine and engine.channels[1] then
engine.channels[1]:nextEvent()
end
check("Ch1's tempo command set engine.tempo to 144, not the 0x100 default",
engine and engine.tempo == 144)
-- pokered data/maps/objects/ViridianForest.asm: the south gate warps land
-- around (16-18, 47); one step north of that is inside the forest proper.
local MAP = "VIRIDIAN_FOREST"
local STAND = { x = 16, y = 46, facing = "up" }
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
local ow = game.overworld
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
local function nearestWalkable()
for dy = -3, 3 do
for dx = -3, 3 do
local cx, cy = STAND.x + dx, STAND.y + dy
if ow.map:isWalkableCell(cx, cy) then return cx, cy end
end
end
return nil
end
local cx, cy = nearestWalkable()
if cx then
U.log("start cell blocked, standing at", cx, cy, "instead")
U.teleport(game, MAP, cx, cy, "up")
end
end
if game.save and game.save.options and game.save.options.musicVol == 0 then
U.log("FAIL musicVol is 0 -- turn it up, this run is silent by design")
end
U.log("Standing in Viridian Forest; Music_Dungeon2 is playing now.")
U.log("Right: four voices at a brisk clip -- lead melody, a counter line")
U.log("underneath it, a soft rounded bass, and a hi-hat tick on top.")
U.log("Wrong (the bug): one thin lonely voice at a noticeably slower,")
U.log("draggy tempo, with no bass or hi-hat at all.")
U.log("Also listen to that bass note's timbre -- it should be a soft")
U.log("rounded triangle-ish tone, not a harsh buzzy rasp (waveBanks).")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,73 @@
-- Manual check that the Yellow intro now runs long enough for
-- Music_YellowIntro's descending run and long final note to be audible
-- before title.asm's unconditional StopAllMusic swaps in the title theme
-- (#523). Never taps a button: Game:load() already pushed YellowIntro,
-- so this driver only watches it play out at real, unaccelerated speed.
-- POKEPORT_SPEED must stay unset -- Music runs on its own real-time 60Hz
-- accumulator (Game:update), decoupled from the fast-forwardable logic
-- clock, so speeding up the driver would desync the exact ordering under
-- test here instead of just playing it back faster.
-- POKEPORT_DRIVER=tests/drivers/yellow_intro_bug523_test.lua POKEPORT_IDENTITY=bug523 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Music = require("src.core.Music")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
check("booted into a state (Yellow intro, per bootScreens)",
game.stack:top() ~= nil)
if game.save and game.save.options and game.save.options.musicVol == 0 then
U.log("FAIL musicVol is 0 -- turn it up, this run is silent by design")
end
local sawIntroSong = false
local sawTitleSong = false
-- Music.lua keeps the current song in a module-local `state` table with
-- no public getter, so watch the one seam that is public: wrap Music.play
-- itself for the life of this driver and log what it's handed. Both the
-- YellowIntro:beginScenes() pcall and title.asm's StopAllMusic-then-
-- PlaySound land here.
local frame = 0
local originalPlay = Music.play
Music.play = function(data, song, loop, ctx)
if song and song:find("Intro") and not sawIntroSong then
sawIntroSong = true
U.log("Music_YellowIntro started")
end
if song == "Music_TitleScreen" and not sawTitleSong then
sawTitleSong = true
U.log("Music_TitleScreen started")
end
return originalPlay(data, song, loop, ctx)
end
-- 40s ceiling counted from driver start, not from the intro song: roughly 500 frames of
-- boot and bootScreens run before Music_YellowIntro begins, and the title swap lands
-- around frame 1680 (28s), so a 20s ceiling expired before the moment under test (#523).
while frame < 40 * 60 and not sawTitleSong do
frame = frame + 1
coroutine.yield()
end
check("Music_YellowIntro started", sawIntroSong)
check("the movie reached the title screen and Music_TitleScreen took over",
sawTitleSong)
U.log("Listen for Music_YellowIntro's ending: a short descending run of")
U.log("notes (F#, F, F#...F#, E, D#, C#) followed by one long low note,")
U.log("right before the title theme cuts in. Before #523 the swap landed")
U.log("early and those descending notes never played -- the track just")
U.log("stopped mid-phrase. The swap itself is still an abrupt cut by")
U.log("design (pokeyellow's title.asm stops the intro song unconditionally,")
U.log("it never waits for it to finish); what changed is how much of the")
U.log("track plays before that cut lands.")
while true do
coroutine.yield()
end
end
+158
View File
@@ -0,0 +1,158 @@
-- Cycling COLORS during a battle must keep the battle song (#484). The
-- pack change rebuilds the live map's baked atlas through
-- OverworldState:reloadMap, and that used to re-enter the map: setMap's
-- PlayMapMusic put the route theme over the battle theme. ReloadMapData
-- (home/reload_tiles.asm) only re-reads the map view and the tileset
-- patterns; map music comes from LoadMapData alone (home/overworld.asm,
-- gated on BIT_NO_MAP_MUSIC), so a reload is not a map entry.
-- The other half matters as much: the hotkey must still work in a battle,
-- so a "fix" that gates key 2 off while BattleState is on top fails here.
-- luajit tests/engine/battle_colors_bug484.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local OverworldState = require("src.world.OverworldController")
-- OverworldController takes its collaborators as file-locals, and Game is
-- only bound when a real state enters; swapping the upvalues is how
-- tests/mod_world_tests.lua drives one of these methods with no dataset.
local function setUpvalue(fn, name, value)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then
debug.setupvalue(fn, i, value)
return true
end
i = i + 1
end
end
local emitted = {}
for _, up in ipairs({
{ "MapLoader", { invalidate = function() end,
invalidateAll = function() end } },
{ "Collision", { load = function() end } },
{ "Game", { data = {} } },
{ "Logger", { warn = function() end, error = function() end } },
{ "Runtime", { emit = function(name, payload)
emitted[#emitted + 1] = { name = name, payload = payload }
end } },
}) do
check(setUpvalue(OverworldState.reloadMap, up[1], up[2]),
"reloadMap still closes over " .. up[1])
end
-- A state stub with just what reloadMap reads: the live map, the player's
-- cell, and a setMap that records the opts instead of loading anything.
-- `shrank` makes the reloaded map report the player's cell out of bounds
-- once, which is the only way past the first setMap.
local function newState(mapId, shrank)
local calls, entered = {}, 0
return {
map = { id = mapId, inBounds = function() return true end },
player = { cellX = 5, cellY = 5, facing = "down" },
neighbors = {},
calls = calls,
setMap = function(self, id, x, y, facing, opts)
calls[#calls + 1] = { id = id, x = x, y = y, facing = facing, opts = opts }
entered = entered + 1
local ok = not (shrank and entered == 1)
self.map = { id = id, inBounds = function() return ok end }
end,
healPoint = function() return { map = "PALLET_TOWN", x = 3, y = 6 } end,
}
end
local live = newState("ROUTE_1")
OverworldState.reloadMap(live, "ROUTE_1", "colors")
eq(#live.calls, 1, "reloading the live map re-enters it exactly once")
local opts = live.calls[1].opts or {}
check(opts.keepMusic == true,
"the reload keeps whatever song is playing (#484): a COLORS cycle "
.. "during a battle must not start the route theme")
eq(opts.via, "reload", "and it is still tagged as a reload, not a warp")
check(opts.seamless == true, "with no transition wipe")
eq(live.calls[1].x, 5, "the player is put back on the cell they were on")
eq(live.calls[1].y, 5, "on both axes")
-- the escape hatch under it is a genuine map change, and PlayMapMusic
-- belongs there: the player is being sent to a heal point
local shrunk = newState("ROUTE_1", true)
OverworldState.reloadMap(shrunk, "ROUTE_1", "colors")
eq(#shrunk.calls, 2, "a map that shrank under the player sends them away")
eq(shrunk.calls[2].id, "PALLET_TOWN", "to their heal point")
check(not (shrunk.calls[2].opts or {}).keepMusic,
"and that one starts the destination's own music")
local other = newState("ROUTE_1")
OverworldState.reloadMap(other, "VIRIDIAN_CITY", "colors")
eq(#other.calls, 0, "reloading a map that is not live touches nothing")
check(emitted[#emitted] and emitted[#emitted].name == "map.reloaded",
"map.reloaded still fires either way")
-- The hotkey half, end to end through the real Game and PaletteFX: with a
-- battle on top the COLORS key is not gated off, and every press moves one
-- rung down the ladder.
local Game = require("src.core.Game")
local PaletteFX = require("src.render.PaletteFX")
local reloads = {}
local ow = {
map = { id = "ROUTE_1" },
reloadMap = function(_, id, reason)
reloads[#reloads + 1] = { id = id, reason = reason }
end,
}
Game.overworld = ow -- PaletteFX.setMode reaches the live overworld this way
local battle = { isOpaque = true } -- stands in for BattleState on the stack
local session = {
overworld = ow,
save = { options = {} },
stack = { states = { ow, battle },
top = function(self) return self.states[#self.states] end },
writeOptions = function() end,
}
local seen, start = {}, PaletteFX.mode
for _ = 1, #PaletteFX.MODES do
Game.keypressed(session, "2")
seen[#seen + 1] = PaletteFX.mode
end
eq(#reloads, #PaletteFX.MODES,
"every COLORS press in a battle still rebuilds the map's atlas")
eq(reloads[1].reason, "colors", "tagged as the COLORS cycle")
eq(reloads[1].id, "ROUTE_1", "on the map the player is standing on")
local distinct = {}
for _, mode in ipairs(seen) do distinct[mode] = true end
local count = 0
for _ in pairs(distinct) do count = count + 1 end
eq(count, #PaletteFX.MODES,
"the palette visibly cycles: one press per mode walks the whole ladder")
eq(PaletteFX.mode, start, "and lands back where it started")
eq(session.save.options.colors, PaletteFX.mode,
"the choice is written to options like it is in the overworld")
-- the pre-existing gate is unchanged: a script driving the overworld still
-- holds the key off, because reloadMap rebuilds the live NPC array
local busy = {
overworld = ow,
save = { options = {} },
stack = { states = { ow }, top = function(self) return self.states[1] end },
writeOptions = function() end,
}
ow.runner = { isRunning = function() return true end }
local before = PaletteFX.mode
local reloadsBefore = #reloads
Game.keypressed(busy, "2")
eq(PaletteFX.mode, before, "a running map script still holds COLORS off")
eq(#reloads, reloadsBefore, "and nothing reloads under it")
T.finish("battle_colors_bug484")
+128
View File
@@ -0,0 +1,128 @@
-- The Silph Scope unveil in the POKEMON_TOWER_6F battle (#492).
--
-- PrintBeginningBattleText .isMarowak (engine/battle/common_text.asm:49-60):
-- with the scope in the bag the battle still ENTERS disguised -- InitWildBattle
-- takes its .isGhost branch on wCurOpponent == RESTLESS_SOUL either way -- and
-- the scope only buys the unveil that is played over the disguise:
-- EnemyAppearedText, UnveiledGhostText, LoadEnemyMonData, MarowakAnim, then
-- WildMonAppearedText. The port used to hand the scope-carrying player a
-- battle that opened with MAROWAK already on screen and no unveil at all.
--
-- The story3 dispatch half (which branch the trigger picks, and that the ball
-- dodge survives the unveil) is tests/parity_marowak.lua and
-- tests/parity_marowak_ball.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local BattleState = require("src.battle.BattleState")
T.check(type(BattleState.makeUnveiledGhost) == "function",
"BattleState:makeUnveiledGhost exists")
T.check(BattleState.makeUnveiledGhost ~= BattleState.makeGhost,
"the unveiled battle is not just the scope-less ghost battle")
local REAL_SPRITE = { marker = "the MAROWAK front pic" }
local function newBattle()
return setmetatable({
kind = "wild",
queue = {},
frame = 0,
data = { text = {}, pokemon = {} },
enemy = { name = "MAROWAK", sprite = REAL_SPRITE,
mon = { species = "MAROWAK", level = 30 } },
}, BattleState)
end
-- ------------------------------------------------------- entering disguised
local b = newBattle()
b:makeUnveiledGhost()
T.eq(b.enemy.name, "GHOST",
"with the scope the battle still OPENS as the GHOST (#492)")
T.check(b.enemy.sprite ~= REAL_SPRITE,
"and wears the ghost pic, not the MAROWAK one")
T.check(b.scopeReveal == true, "the unveil is armed")
T.check(not b.ghost,
"but IsGhostBattle stays false: the mon can be attacked and flees normally")
T.eq(b.ghostReal.name, "MAROWAK", "the real nick is kept for LoadEnemyMonData")
T.eq(b.ghostReal.sprite, REAL_SPRITE, "...and the real pic with it")
local ghostSprite = b.enemy.sprite
-- ------------------------------------------------------------- the row order
-- .isMarowak prints over the "GHOST appeared!" box enter() already queued: the
-- unveil line, MarowakAnim (a wait row parked over the fx state machine), then
-- "Wild MAROWAK appeared!" under the restored name.
b:queueScopeReveal()
T.eq(#b.queue, 4, "the unveil is four queue rows")
T.check(type(b.queue[1].text) == "string"
and b.queue[1].text:find("SILPH SCOPE", 1, true) ~= nil,
"row 1 is UnveiledGhostText")
T.check(type(b.queue[2].fn) == "function", "row 2 starts MarowakAnim")
T.eq(b.queue[3].wait, BattleState.GHOST_REVEAL_FRAMES,
"row 3 holds the queue for the length of the animation")
T.check(type(b.queue[4].text) == "string"
and b.queue[4].text:find("MAROWAK", 1, true) ~= nil,
"row 4 is WildMonAppearedText under the real name")
T.check(b.queue[4].text:find("GHOST", 1, true) == nil,
"...and not under the disguise")
-- ------------------------------------------------------------ the animation
-- MarowakAnim (engine/battle/ghost_marowak_anim.asm): FlashSprite8Times, the
-- rOBP1 fade-out, the pic swap, the fade-in. The whole point of #492 is that
-- the swap comes AFTER the ghost has faded off the paper, so every frame up to
-- there still shows the GHOST.
b.queue[2].fn()
T.check(b.ghostReveal ~= nil, "the fn arms the reveal state")
local FLASH = BattleState.GHOST_FLASH_FRAMES
local FADE_OUT = BattleState.GHOST_FADE_OUT_FRAMES
local swapFrame, fades, stillGhost = nil, {}, true
for frame = 1, BattleState.GHOST_REVEAL_FRAMES do
b:updateFx()
local pf = b.picFx and b.picFx[b.enemy]
fades[frame] = pf and pf.fade
if not swapFrame and b.enemy.name ~= "GHOST" then swapFrame = frame end
if frame <= FLASH + FADE_OUT and b.enemy.name ~= "GHOST" then
stillGhost = false
end
end
T.check(stillGhost,
"the GHOST is on screen for the whole flash and fade-out (#492)")
T.eq(swapFrame, FLASH + FADE_OUT + 1,
"the pic and nick swap the frame after the ghost has faded out")
T.eq(b.enemy.name, "MAROWAK", "the real nick is back by the end")
T.eq(b.enemy.sprite, REAL_SPRITE, "and the real pic with it")
T.check(b.enemy.sprite ~= ghostSprite, "the ghost pic is gone")
-- FlashSprite8Times xors rOBP1 with $80 every 10 frames, so the body alternates
-- between two shades rather than sitting opaque
T.eq(fades[5], 1, "the flash opens on the ghost at full strength")
T.eq(fades[15], 0.5, "...drops a shade 10 frames in")
T.eq(fades[25], 1, "...and comes back")
T.check(fades[FLASH + FADE_OUT] == 0,
"the ghost is fully faded out when the swap lands")
T.check(fades[FLASH + FADE_OUT + 1] > 0
and fades[FLASH + FADE_OUT + 1] < 1,
"the MAROWAK fades IN rather than popping on")
T.eq(fades[BattleState.GHOST_REVEAL_FRAMES - 1], 1,
"and reaches full strength before the box turns")
T.check(b.ghostReveal == nil, "the reveal state clears itself")
T.check(b.scopeReveal == nil, "so does the flag that gated the intro cry")
local pf = b.picFx and b.picFx[b.enemy]
T.check(pf == nil or pf.fade == nil,
"no leftover alpha on the pic for the rest of the battle")
-- ------------------------------------------------ the scope-less ghost still
-- Nothing above may change the battle you get WITHOUT the scope: still a real
-- ghost battle, still no unveil queued.
local noScope = newBattle()
noScope:makeGhost()
T.check(noScope.ghost == true, "without the scope IsGhostBattle is true")
T.eq(noScope.enemy.name, "GHOST", "and the disguise is the same one")
T.check(noScope.scopeReveal == nil, "with no unveil armed")
T.eq(#noScope.queue, 0, "and no unveil rows queued")
T.finish("ghost unveil")
@@ -0,0 +1,59 @@
-- #575: HostShell.restart on Android must never reach love.event.quit
-- ("restart") -- the vendored love.cpp loops runlove() in-process on
-- "restart" and the second PHYSFS_init crashes ("already initialized").
-- The fix prefers the love.system.restartApp JNI bridge (which kills the
-- process, so a true return is never observed live) and, on an old APK
-- whose liblove lacks the bridge, falls back to a CLEAN quit with no
-- argument. Desktop keeps the in-process quit("restart").
-- luajit tests/engine/host_restart_android_bug575.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local HostShell = require("src.core.HostShell")
local quits = {}
love.event = {
-- record the argument distinctly from "called with none": quit() and
-- quit("restart") are the whole difference this test pins
quit = function(...)
quits[#quits + 1] = { n = select("#", ...), arg = (...) }
end,
}
local osName = "Android"
local restartCalls = 0
love.system = love.system or {}
love.system.getOS = function() return osName end
-- bridge present and schedulable: restart goes through it, quit untouched
love.system.restartApp = function() restartCalls = restartCalls + 1 return true end
HostShell.restart()
eq(restartCalls, 1, "Android restart prefers the restartApp bridge (#575)")
eq(#quits, 0, "a scheduled relaunch never touches love.event.quit")
-- bridge present but could not schedule: clean quit, never quit("restart")
love.system.restartApp = function() restartCalls = restartCalls + 1 return false end
HostShell.restart()
eq(restartCalls, 2, "the bridge is still tried first")
eq(#quits, 1, "a failed schedule falls back to one quit")
eq(quits[1].n, 0, "and it is a bare quit(), not quit(\"restart\")")
-- old APK, no bridge compiled in: same clean quit fallback
love.system.restartApp = nil
HostShell.restart()
eq(#quits, 2, "a bridge-less APK quits cleanly instead of crashing")
eq(quits[2].n, 0, "again with no restart argument")
-- desktop (no AppImage in a test environment) keeps the in-process restart
if not os.getenv("APPIMAGE") then
osName = "OS X"
HostShell.restart()
eq(quits[3] and quits[3].arg, "restart",
"non-Android still restarts in-process")
end
T.finish("host_restart_android_bug575")
+148
View File
@@ -0,0 +1,148 @@
-- #578: the launcher's "Add an index" prompt (and the rename / find-search
-- fields) accepted no typing on Android, because nothing ever called
-- love.keyboard.setTextInput(true) -- mobile LOVE only delivers
-- love.textinput while it is armed. Every site that opens a text field must
-- arm, every site that closes one must disarm, and disarm must be a no-op on
-- desktop where the hosted save editor depends on text input staying on
-- (tools/save-editor/Kit.lua, #529). A touch screen also has no ctrl+V, so
-- the prompt grew a PASTE chip; both paste paths share _pasteIndexUrl and
-- both honor the whitespace strip and the MAX_INDEX_URL cap.
-- luajit tests/engine/launcher_text_input_bug578.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
-- record every setTextInput transition; the assertions read this log
local textInputLog = {}
love.keyboard.setTextInput = function(on)
textInputLog[#textInputLog + 1] = on
end
local function lastArm() return textInputLog[#textInputLog] end
local clipboard = ""
love.system = love.system or {}
love.system.getClipboardText = function() return clipboard end
-- _commitAddIndex hands the typed URL to ModIndex.addSource; a canned
-- failure keeps the commit path off the network and out of options.lua
local addSourceUrl = nil
package.loaded["src.mods.ModIndex"] = {
addSource = function(url) addSourceUrl = url return nil, "offline" end,
}
-- _commitRename goes through SaveData.renameSlot; record the call
local renamed = nil
package.loaded["src.core.SaveData"] = {
renameSlot = function(version, id, text) renamed = { version, id, text } end,
}
local RomImporter = require("src.import.RomImporter")
local ri = setmetatable({
android = true, workState = nil, tab = "find",
ready = {}, slots = { red = { { id = "s1", label = "OLD" } } },
slotScroll = {}, activeSlot = {},
}, RomImporter)
ri._refreshSlots = function() end -- rename commit relists; nothing to relist
-- ---- index prompt: arm on open, disarm on escape and on commit ------------
ri:_promptAddIndex()
check(ri._indexPrompt ~= nil, "the add-index prompt opens")
eq(lastArm(), true, "opening the prompt arms setTextInput (#578)")
-- typed input strips whitespace (URLs never contain a literal space)
ri:textinput("https://ex ample.com\n/idx")
eq(ri._indexPrompt.text, "https://example.com/idx",
"typed input lands with whitespace stripped")
ri:keypressed("escape")
check(ri._indexPrompt == nil, "escape closes the prompt")
eq(lastArm(), false, "and disarms setTextInput")
ri:_promptAddIndex()
ri._indexPrompt.text = "https://example.com/index.json"
ri:keypressed("return")
check(ri._indexPrompt == nil, "enter commits and closes the prompt")
eq(lastArm(), false, "commit disarms setTextInput too")
eq(addSourceUrl, "https://example.com/index.json",
"the committed text reaches ModIndex.addSource")
check(ri.findNotice and ri.findNotice.ok == false,
"a rejected source surfaces as a notice, not a crash")
-- ---- PASTE chip: same entry point the touch screen uses -------------------
ri:_promptAddIndex()
-- the chip rect is what draw() published last frame (pinned: modal chrome
-- ignores the page-scroll band); mousepressed hit-tests it while the
-- prompt is up and everywhere else the prompt swallows the press
ri._indexPasteRect = { x = 10, y = 10, width = 60, height = 24, pinned = true }
clipboard = " https://example.com/mods/index.json\n"
ri:mousepressed(200, 200, 1)
eq(ri._indexPrompt.text, "", "a press outside the chip pastes nothing")
ri:mousepressed(20, 20, 1)
eq(ri._indexPrompt.text, "https://example.com/mods/index.json",
"the PASTE chip lands the clipboard with whitespace stripped (#578)")
-- the cap holds through the button path: a 300-char clipboard cannot
-- overflow MAX_INDEX_URL (200)
ri._indexPrompt.text = ""
clipboard = string.rep("a", 300)
ri:mousepressed(20, 20, 1)
eq(#ri._indexPrompt.text, 200, "the PASTE chip enforces MAX_INDEX_URL")
-- and through ctrl/cmd+V, which used to skip the cap entirely
ri._indexPrompt.text = ""
local savedIsDown = love.keyboard.isDown
love.keyboard.isDown = function() return true end
ri:keypressed("v")
love.keyboard.isDown = savedIsDown
eq(#ri._indexPrompt.text, 200, "ctrl+V routes through the same cap (#578)")
ri:keypressed("escape")
-- ---- rename field: arm on open, disarm on escape and on commit ------------
ri:_beginRename("red", "s1")
check(ri._rename ~= nil, "the rename modal opens")
eq(lastArm(), true, "opening the rename arms setTextInput")
ri:keypressed("escape")
check(ri._rename == nil, "escape closes the rename")
eq(lastArm(), false, "and disarms setTextInput")
ri:_beginRename("red", "s1")
ri:textinput("!")
ri:keypressed("return")
eq(lastArm(), false, "committing the rename disarms setTextInput")
eq(renamed and renamed[3], "OLD!", "the commit reaches SaveData.renameSlot")
-- ---- find-search field: arm on rect press, disarm on escape ---------------
ri.findSearchRect = { x = 100, y = 100, width = 80, height = 20 }
ri:mousepressed(110, 110, 1)
check(ri._findSearchFocus == true, "pressing the search field takes focus")
eq(lastArm(), true, "and arms setTextInput")
ri:keypressed("escape")
check(ri._findSearchFocus == false, "escape drops the search caret")
eq(lastArm(), false, "and disarms setTextInput")
-- a press elsewhere on the find tab also drops the caret and disarms
ri:mousepressed(110, 110, 1)
eq(lastArm(), true, "refocus for the click-away case")
ri:mousepressed(400, 400, 1)
check(ri._findSearchFocus == false, "a click away drops the caret")
eq(lastArm(), false, "and disarms setTextInput")
-- ---- desktop contract (#529): disarm never lowers off Android -------------
ri.android = false
ri:_promptAddIndex()
eq(lastArm(), true, "desktop still arms (harmless, already on)")
local before = #textInputLog
ri:keypressed("escape")
eq(#textInputLog, before,
"desktop disarm is a no-op: the hosted save editor keeps text input on "
.. "(#529)")
T.finish("launcher_text_input_bug578")
+156
View File
@@ -0,0 +1,156 @@
-- Menu button sounds (#570). HandleMenuInput_ (home/window.asm) replays
-- SFX_PRESS_AB whenever the watched keys it just returned on include
-- PAD_A | PAD_B, unless BIT_NO_MENU_BUTTON_SOUND is set in wMiscFlags;
-- DisplayListMenuID watches PAD_A | PAD_B | PAD_SELECT (home/list_menu.asm),
-- so SELECT is answered but never beeps; DisplayOptionMenu plays it only at
-- .exitMenu (engine/menus/main_menu.asm), i.e. B, START and A on CANCEL.
-- Silence is half the contract: a click the original does not make is as
-- wrong as one it makes and the port swallows.
-- luajit tests/engine/menu_click_bug570.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
-- The UI modules reach for Sound lazily (require inside the press branch),
-- so seeding package.loaded before they load is enough to see every cue.
local played = {}
package.loaded["src.core.Sound"] = {
play = function(_, key) played[#played + 1] = key end,
}
local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local OptionsMenu = require("src.ui.OptionsMenu")
local PartyMenu = require("src.ui.PartyMenu")
-- A stub game with the three things a menu touches: a stack it can pop
-- itself off, an input queue that is one fixed step of edges, and a
-- non-nil `data` (the beep helpers skip themselves when data is nil, which
-- is how the harness-driven screens stay silent).
local function newGame()
local game = { data = {}, save = { options = {}, party = {},
flags = {}, inventory = {} } }
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
game.input = {
queue = {},
wasPressed = function(self, btn) return self.queue[btn] or false end,
isDown = function() return false end,
}
function game:writeOptions() end
return game
end
-- one fixed step with `btn` on its edge; returns the cues it produced
local function press(state, btn)
played = {}
state.game.input.queue = { [btn] = true }
state:update(1 / 60)
state.game.input.queue = {}
return played
end
local function beeps(state, btn)
local cues = press(state, btn)
for _, key in ipairs(cues) do
if key ~= "Press_AB" then
check(false, "unexpected cue " .. tostring(key) .. " on " .. btn)
end
end
return #cues
end
-- ITEM and POKéDEX are both DisplayListMenuID lists, and cancelling either
-- was the silent half of the report.
local function newList(opts)
local game = newGame()
local list = ListMenu.new(game, "BAG",
{ { label = "POTION", value = "POTION" },
{ label = "ANTIDOTE", value = "ANTIDOTE" } }, opts)
game.stack:push(list)
return list, game
end
local list = newList({ onChoose = function() end })
eq(beeps(list, "a"), 1, "the first A on an item in ITEM clicks once (#570)")
eq(beeps(list, "b"), 1, "B out of a list clicks once (ITEM, POKéDEX cancel)")
local moves = newList({ onChoose = function() end,
onSelectKey = function() end })
eq(beeps(moves, "select"), 0,
"SELECT is watched by DisplayListMenuID but outside its PAD_A | PAD_B "
.. "sound test, so the swap key stays silent")
eq(beeps(moves, "up"), 0, "moving the cursor is silent")
eq(beeps(moves, "down"), 0, "moving the cursor is silent both ways")
-- Both PC sessions set BIT_NO_MENU_BUTTON_SOUND for their whole run
-- (engine/menus/pc.asm, engine/menus/players_pc.asm), so their lists are
-- the control case: the same code path, deliberately mute.
local pc = newList({ noSound = true, onChoose = function() end })
eq(beeps(pc, "a"), 0, "a PC list holds BIT_NO_MENU_BUTTON_SOUND: A is mute")
eq(beeps(pc, "b"), 0, "and so is backing out of it")
-- an emptied bag still answers A and B, and HandleMenuInput_ does not care
-- that the list has no rows
local emptyGame = newGame()
local empty = ListMenu.new(emptyGame, "BAG", {}, {})
emptyGame.stack:push(empty)
eq(beeps(empty, "a"), 1, "A out of an empty list still clicks")
-- OPTION: .exitMenu is the only PlaySound in DisplayOptionMenu
local function newOptions()
local game = newGame()
local om = OptionsMenu.new(game)
game.stack:push(om)
return om
end
local om = newOptions()
om.index = 1
eq(beeps(om, "a"), 0, "A on a setting row re-loops in the original: silent")
eq(beeps(om, "right"), 0, "and the Left/Right toggles are silent too")
eq(beeps(om, "left"), 0, "in both directions")
om.index = #om.rows + 1 -- CANCEL sits one past the hook-built rows
eq(beeps(om, "a"), 1, "A on CANCEL in OPTION clicks once (#570)")
eq(beeps(newOptions(), "b"), 1, "B out of OPTION clicks once (#570)")
eq(beeps(newOptions(), "start"), 1, "START leaves OPTION the same way")
-- POKéMON: HandlePartyMenuInput runs through HandleMenuInput_
local function newParty()
local game = newGame()
local pm = PartyMenu.new(game, {})
game.stack:push(pm)
return pm
end
eq(beeps(newParty(), "b"), 1, "B out of POKéMON clicks once (#570)")
eq(beeps(newParty(), "a"), 1, "and A in POKéMON clicks")
eq(beeps(newParty(), "down"), 0, "moving between slots is silent")
-- The start menu itself already beeped before the fix; pinned here because
-- an over-eager patch that beeps on every watched key would break it.
-- draw_start_menu.asm's mask includes PAD_START, and START is outside the
-- PAD_A | PAD_B sound test, so closing the menu with it is silent.
local function newStartMenu()
local game = newGame()
local m = Menu.new(game, { { label = "POKéDEX", onSelect = function() end },
{ label = "ITEM", onSelect = function() end } },
{ startCloses = true })
game.stack:push(m)
return m
end
eq(beeps(newStartMenu(), "a"), 1, "A on a start-menu row clicks")
eq(beeps(newStartMenu(), "b"), 1, "B closing the start menu clicks")
eq(beeps(newStartMenu(), "start"), 0,
"START closes the start menu silently: it is watched but not in the "
.. "PAD_A | PAD_B branch")
T.finish("menu_click_bug570")
+141
View File
@@ -0,0 +1,141 @@
-- Boxed-menu row geometry (#564 start menu, #572 the PC and Pokédex windows).
-- pokered's menu boxes hug their choices: the last one lands on the bottom
-- interior row, so a double-spaced menu whose box is n*2+2 tall starts at
-- ty+2 -- draw_start_menu.asm (TextBoxBorder at 10,0 b=$0e, then hlcoord
-- 12,2 with wTopMenuItemY 2), players_pc.asm (0,0 b=8 c=14, hlcoord 2,2).
-- The port drew every row one tile high, which parked the text against the
-- top border and left the bottom interior row blank.
-- ROM-free: builds the real screens over tests/fixture_data.
-- luajit tests/engine/menu_row_offset_bug564.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local check, eq, same = T.check, T.eq, T.same
local Data = T.fixtures.load()
local Font = require("src.render.Font")
local SaveData = require("src.core.SaveData")
local Screens = require("src.ui.Screens")
local StateStack = require("src.core.StateStack")
local Theme = require("src.ui.Theme")
local function newGame()
local stack = setmetatable({}, { __index = StateStack })
stack:init()
local save = SaveData.newGame()
save.flags = save.flags or {}
save.flags.EVENT_GOT_POKEDEX = true
save.pokedex = { seen = { FIXMON_A = true }, owned = { FIXMON_A = true } }
save.inventory = { FIX_POTION = 3 }
return {
data = Data, save = save, stack = stack,
input = {
queue = {},
wasPressed = function(self, btn) return self.queue[btn] or false end,
isDown = function() return false end,
},
}
end
-- Draw the menu with the font stubbed out and report, in tile rows, where
-- the labels, the ▶ cursor and the "more below" arrow actually landed.
local function layout(menu)
local realDraw, realCode = Font.draw, Font.drawCode
local out = { rows = {}, cols = {} }
Font.draw = function(_, x, y)
out.rows[#out.rows + 1] = y / 8
out.cols[#out.cols + 1] = x / 8
return 8
end
Font.drawCode = function(code, x, y)
if code == Theme.cursor then
out.cursor, out.cursorCol = y / 8, x / 8
elseif code == Theme.moreArrow then
out.arrow = y / 8
end
end
local ok, err = pcall(menu.draw, menu)
Font.draw, Font.drawCode = realDraw, realCode
if not ok then error(err, 0) end
return out
end
-- Every boxed menu owes the same two things to its border, and the second
-- is the one #564/#572 were about: a menu that starts at ty+1 leaves the
-- bottom interior row empty and the text crowded under the top edge.
local function boxRules(menu, label)
local at = layout(menu)
local first, last = at.rows[1], at.rows[#at.rows]
check(first and first >= menu.ty + 1 and last <= menu.ty + menu.th - 2,
label .. ": every choice sits inside the border (rows "
.. tostring(first) .. ".." .. tostring(last) .. ", interior "
.. (menu.ty + 1) .. ".." .. (menu.ty + menu.th - 2) .. ")")
eq(last, menu.ty + menu.th - 2,
label .. ": the last choice lands on the bottom interior row")
eq(at.cursor, at.rows[menu.index - menu.scroll],
label .. ": the cursor shares its row with the highlighted choice")
eq(at.cursorCol, menu.tx + 1, label .. ": the cursor sits one tile in")
return at
end
-- ---------------------------------------------------------------- #564
-- draw_start_menu.asm: box at (10,0) 14 rows tall, first choice at row 2,
-- seven double-spaced choices, so the last is on row 14 and the blank row
-- is the one under the top border.
local game = newGame()
local start = Screens.push(game, "StartMenu")
eq(#start.items, 7, "vanilla start menu has seven rows with the dex")
local at = boxRules(start, "start menu")
same(at.rows, { 2, 4, 6, 8, 10, 12, 14 }, "start menu rows match hlcoord 12,2")
eq(at.cols[1], start.tx + 2, "start menu labels sit two tiles in")
start.index = 3
eq(layout(start).cursor, 6, "cursor follows the third row down to row 6")
-- The "more below" glyph rides the bottom border, because the bottom
-- interior row now belongs to a choice (#564).
local Menu = require("src.ui.Menu")
local many = {}
for i = 1, 10 do many[i] = { label = "ROW" .. i } end
local scrolled = Menu.new(game, many,
{ tx = 9, ty = 0, tw = 11, maxVisible = 8 }) -- StartMenu's own opts
local sat = layout(scrolled)
eq(sat.arrow, scrolled.ty + scrolled.th - 1,
"the more-below arrow is on the border row")
check(sat.arrow > sat.rows[#sat.rows],
"the arrow is clear of the last visible choice")
-- ---------------------------------------------------------------- #572
-- players_pc.asm: TextBoxBorder (0,0) b=8 c=14 -> a 16x10 box whose four
-- choices start at hlcoord 2,2.
local pc = Screens.push(newGame(), "PlayerPC")
eq(pc.th, 10, "player's PC box is 10 tiles tall")
same(boxRules(pc, "player's PC").rows, { 2, 4, 6, 8 },
"player's PC rows match hlcoord 2,2")
-- The Pokédex side menu (pokedex.asm PokedexMenuItemsText): DATA / CRY /
-- AREA / QUIT, opened by choosing a seen species off the dex list.
local dexGame = newGame()
local dexList = Screens.push(dexGame, "PokedexMenu", {})
local entry = dexList.items[1]
eq(entry.value, "FIXMON_A", "the fixture dex list opens on a seen species")
dexList.onChoose(entry, dexList)
local side = dexGame.stack:top()
check(side ~= dexList, "choosing an entry pushes the side menu")
same(boxRules(side, "Pokédex side menu").rows, { 10, 12, 14, 16 },
"side menu rows fill its box")
-- The bag's USE / TOSS box is the one menu pokered draws without a blank
-- leading row: USE_TOSS_MENU_TEMPLATE (data/text_boxes.asm) is (13,10) to
-- (19,14) with the text at 15,11, so two double-spaced choices exactly
-- fill rows 11 and 13. #284 sized the port's box to that, which is why
-- the shared "first choice at ty+2" rule pushes TOSS onto the border here.
local bagGame = newGame()
local bag = Screens.push(bagGame, "BagMenu")
bag.onChoose(bag.items[1], bag)
local useToss = bagGame.stack:top()
eq(#useToss.items, 2, "the bag submenu is USE / TOSS")
boxRules(useToss, "bag USE/TOSS")
T.finish("menu_row_offset_bug564")

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