Compare commits

...

45 Commits

Author SHA1 Message Date
bryanthaboi ac01135ca8 Merge pull request #615 from bryanthaboi/dev 2026-08-01 16:53:54 -04:00
bryanthaboi 5e89e35e02 Merge pull request #614 from castdrian/metal 2026-08-01 16:52:41 -04:00
bryanthaboi 7e7afeaf82 Mask the self-hosted runner's home path in release logs 2026-08-01 16:45:37 -04:00
Adrian Castro c6fa6d294b fix(ios): support LÖVE 12 file bridge 2026-08-01 22:22:49 +02:00
bryanthaboi fc2d17fc78 Merge pull request #612 from bryanthaboi/dev 2026-08-01 16:03:28 -04:00
bryanthaboi 9f072285a6 Merge pull request #611 from ShaneMcGovernIE/fix/oak-last-ball-601 2026-08-01 16:00:52 -04:00
bryanthaboi c58ebe4b9b Merge pull request #609 from castdrian/metal 2026-08-01 15:59:33 -04:00
Shane McGovern 0dd187fe30 Fix wrong dialogue on Prof. Oak's leftover ball (#601)
After the player picks a starter and the rival takes his, every ball on
the lab table fell through to 'Those are POKé BALLs' instead of the
leftover-mon line.  Port the pokered OaksLabSelectedPokeBallScript ->
OaksLabLastMonScript beat: with EVENT_GOT_STARTER set, Oak turns to face
the player and reads 'That's PROF.OAK's last Pokémon!'.  The ROM's
'#MON' ligature is spelled out as Pokémon.

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

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

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

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

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

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

No battle-render changes: a mod lays out the two screens (including any
battle split) itself. Ships with a unit test, no-mod parity via
gate_hooks, and docs/modding.md (D14).
2026-08-01 09:35:24 +02:00
bryanthaboi 50947eec2a Merge pull request #489 from mresnick67/android-step-bridge 2026-08-01 00:21:40 -04:00
bryanthaboi 2624bb0210 Merge pull request #537 from sirj0k3r/hook/exp 2026-08-01 00:21:20 -04:00
bryanthaboi 6e9685f302 Merge pull request #538 from sirj0k3r/hook/low-health 2026-08-01 00:21:10 -04:00
bryanthaboi 898bf0c71e Merge pull request #560 from bryanthaboi/dev 2026-07-31 23:55:26 -04:00
bryanthaboi 5a7add8eaa Merge pull request #554 from andrewqsantos/feature/mobile-safe-area 2026-07-31 23:54:11 -04:00
bryanthaboi 66334dfacd Merge pull request #558 from erereck/fix/mod-configurable-bag-capacity 2026-07-31 23:53:54 -04:00
bryanthaboi a33f3b1ceb mobile fixes 2026-07-31 23:47:28 -04:00
erereck 6e724cedf7 Honor modded bag capacity 2026-08-01 00:32:00 -03:00
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
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
sirj0k3r ddcce5b95a + Implemented Exp hook 2026-07-31 21:14:23 +01:00
sirj0k3r 6fd9741fab + Implemented Low Health Alert hook for modding 2026-07-31 21:12:21 +01:00
Myles Resnick caa65182dc Android: hardware step-counter bridge for step-sync mods
love.system.syncHealthSteps() now exists on Android, matching the iOS
Health bridge merged in #452 and using the same JNI route as the SAF
picker (wrap_System.cpp -> System.cpp -> common/android.cpp ->
GameActivity over JNI):

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 11:11:54 -04:00
128 changed files with 9632 additions and 577 deletions
+11
View File
@@ -45,6 +45,12 @@ jobs:
runs-on: [self-hosted, macOS]
steps:
# The self-hosted runner lives under the machine owner's home
# directory; mask it first so absolute paths in every later step's
# output show up as *** in the public workflow logs.
- name: Mask runner paths
run: echo "::add-mask::$HOME"
- name: Checkout
uses: actions/checkout@v4
with:
@@ -170,6 +176,11 @@ jobs:
set -euo pipefail
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
- name: Install xcbeautify
run: |
set -euo pipefail
brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
- name: Build iOS
run: |
set -euo pipefail
+1 -1
View File
@@ -49,7 +49,7 @@ function love.conf(t)
t.window.minwidth = 480
t.window.minheight = 360
end
t.version = "11.5"
t.version = love._os == "iOS" and "12.0" or "11.5"
t.window.vsync = 1
t.modules.joystick = true
t.modules.physics = false
+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!",
}),
},
},
}
+14 -5
View File
@@ -6,6 +6,8 @@
-- takes it ("I'll take this one, then!") and both balls disappear.
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
-- OaksLabRivalTakePokeBallScript.
-- * Leftover ball (after the pick): Oak turns and reads the last-mon
-- line instead of re-offering the starter (OaksLabLastMonScript, #601).
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak
-- has walked you in, else "gramps isn't around" (#218); with
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
@@ -23,7 +25,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "jump_if_true", 20 }, -- 2
-- no picking until Oak has walked you in (OaksLabScript gating)
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 20 }, -- 4
{ "jump_if_false", 22 }, -- 4
-- the Pokédex "new species" entry shows before the ask (predef
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
-- sets the owned bits so ShowPokedexData prints height/weight/text;
@@ -31,7 +33,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "push_screen", "DexEntryMenu",
{ species = species, forceOwned = true } }, -- 5
{ "ask", askText }, -- 6
{ "jump_if_false", 21 }, -- 7
{ "jump_if_false", "end" }, -- 7
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
-- inside give_pokemon). Show the received text first so the
-- nickname prompt follows "you got X", matching Gen1.
@@ -52,9 +54,16 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
or "BULBASAUR" } }, -- 17
{ "jump", 21 }, -- 18
{ "jump", 21 }, -- 19 (spacer)
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
{ "jump", "end" }, -- 18
{ "jump", "end" }, -- 19 (spacer)
-- a leftover ball after the player's pick: Oak turns to face the
-- player and reads the last-mon line instead of re-offering the
-- starter (scripts/OaksLab.asm OaksLabSelectedPokeBallScript ->
-- OaksLabLastMonScript; #601). The ROM's "#MON" ligature is spelled
-- out as Pokémon here.
{ "face_object", 5, "down" }, -- 20
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 21
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 22
}
end
+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
+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.
+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.
+30 -2
View File
@@ -315,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
@@ -380,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.
@@ -432,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.
+8 -4
View File
@@ -476,10 +476,14 @@ function love.mousepressed(x, y, button, istouch)
-- 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 rather than on the OS keeps a real mouse
-- (DeX, a Chromebook, a USB mouse) working, which an Android-wide return
-- would have broken.
if istouch then return end
-- 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
+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,6 +94,17 @@ 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
@@ -312,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();
}
/**
@@ -426,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) {
@@ -448,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());
@@ -478,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) {
@@ -532,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;
@@ -762,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);
}
@@ -944,4 +1202,182 @@ public class GameActivity extends SDLActivity {
return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so";
}
}
// Dual-screen: mirror the engine's bottom-screen canvas onto a secondary
// physical display. Driven from the engine through love_android_secondary_*
// in src/jni/love/src/common/android.cpp.
private static volatile SecondaryPresentation secondaryPresentation;
private static volatile boolean secondaryEnabled = false;
@Keep
public static void setSecondaryEnabled(final boolean on) {
secondaryEnabled = on;
final GameActivity self = (GameActivity) mSingleton;
if (self == null) return;
self.runOnUiThread(new Runnable() {
@Override public void run() {
if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay();
}
});
}
private static void setupSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
try {
android.hardware.display.DisplayManager dm =
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
if (dm == null) return;
Display chosen = null;
for (Display d : dm.getDisplays()) {
android.graphics.Point size = new android.graphics.Point();
d.getRealSize(size);
Log.d("GameActivity", "display id=" + d.getDisplayId() + " name=" + d.getName()
+ " size=" + size.x + "x" + size.y);
if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) {
chosen = d;
}
}
if (chosen == null) {
Display[] pres =
dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
if (pres != null && pres.length > 0) chosen = pres[0];
}
if (chosen == null) {
Log.d("GameActivity", "no secondary display found");
return;
}
SecondaryPresentation p = new SecondaryPresentation(self, chosen);
p.show();
secondaryPresentation = p;
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
} catch (Throwable t) {
Log.d("GameActivity", "secondary display setup failed: " + t);
secondaryPresentation = null;
}
}
private static void teardownSecondaryDisplay() {
SecondaryPresentation p = secondaryPresentation;
secondaryPresentation = null;
if (p != null) {
try { p.dismiss(); } catch (Throwable t) {}
}
}
@Keep
public static boolean hasSecondaryDisplay() {
return secondaryPresentation != null;
}
@Keep
public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) {
SecondaryPresentation p = secondaryPresentation;
if (p != null && buf != null && w > 0 && h > 0) {
p.updateFrame(buf, w, h);
}
}
private static class SecondaryPresentation extends android.app.Presentation {
private final FrameView frameView;
SecondaryPresentation(Context context, Display display) {
super(context, display);
frameView = new FrameView(context);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
android.view.Window w = getWindow();
if (w != null) {
w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
w.setLayout(WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT);
}
setContentView(frameView);
applyImmersive();
frameView.post(new Runnable() {
@Override public void run() { applyImmersive(); }
});
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) applyImmersive();
}
private void applyImmersive() {
android.view.Window w = getWindow();
if (w == null) return;
if (android.os.Build.VERSION.SDK_INT >= 30) {
w.setDecorFitsSystemWindows(false);
android.view.WindowInsetsController c = w.getInsetsController();
if (c != null) {
c.hide(android.view.WindowInsets.Type.systemBars());
c.setSystemBarsBehavior(
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
} else {
w.getDecorView().setSystemUiVisibility(
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
}
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
frameView.updateFrame(buf, w, h);
}
}
private static class FrameView extends View {
private android.graphics.Bitmap bitmap;
private final android.graphics.Rect dst = new android.graphics.Rect();
private final android.graphics.Paint paint = new android.graphics.Paint();
private final Object lock = new Object();
private int fw, fh;
FrameView(Context context) {
super(context);
paint.setFilterBitmap(false);
paint.setAntiAlias(false);
setBackgroundColor(0xFF000000);
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
synchronized (lock) {
if (bitmap == null || fw != w || fh != h) {
if (bitmap != null) bitmap.recycle();
bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888);
fw = w; fh = h;
}
buf.rewind();
bitmap.copyPixelsFromBuffer(buf);
}
postInvalidate();
}
@Override
protected void onDraw(android.graphics.Canvas canvas) {
synchronized (lock) {
if (bitmap == null || fw == 0 || fh == 0) return;
int vw = getWidth(), vh = getHeight();
int s = Math.min(vw / fw, vh / fh);
if (s < 1) s = 1;
int dw = fw * s, dh = fh * s;
int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
dst.set(dx, dy, dx + dw, dy + dh);
canvas.drawColor(0xFF000000);
canvas.drawBitmap(bitmap, null, dst, paint);
}
}
}
}
+1 -1
View File
@@ -1 +1 @@
11.5
12.0
+17 -38
View File
@@ -1,4 +1,4 @@
# iOS build (LÖVE 11.5)
# iOS build (LÖVE 12.0)
> **Native ROM/mod/save import.** The iOS build ships a Swift
> document-picker bridge (`native/GRPickerBridge.swift` + `GRBootstrap.m`)
@@ -20,20 +20,17 @@
> The note below about a missing "UIDocumentPicker handoff" is
> resolved by this bridge.
macOS + Xcode only. Pins the official **LÖVE 11.5** iOS Xcode tree
(`love-11.5-ios-source.zip` from [love2d/love releases](https://github.com/love2d/love/releases/tag/11.5)),
matching `conf.lua`'s `t.version = "11.5"`.
macOS + Xcode only. Fetches the **LÖVE 12.0** source tree and matching Apple
dependencies from the official [LÖVE source](https://github.com/love2d/love)
and [Apple dependencies](https://github.com/love2d/love-apple-dependencies)
repositories. `conf.lua` declares LÖVE 12.0 on iOS and 11.5 elsewhere.
There is no separate `love2d/love-ios` GitHub repo for 11.5; the release zip
**is** the vendored iOS project (Xcode project under
`love-src/platform/xcode/love.xcodeproj`, target `love-ios`).
Pin file: [`LOVE_VERSION`](./LOVE_VERSION) → `11.5`.
Pin file: [`LOVE_VERSION`](./LOVE_VERSION) → `12.0`.
## Quick start (simulator)
```bash
# Fetch LÖVE 11.5 iOS sources (once) + build for Simulator
# Fetch LÖVE 12.0 iOS sources and dependencies (once) + build for Simulator
scripts/build_ios.sh --fetch
```
@@ -80,10 +77,10 @@ Manual out-of-band steps:
| Path | Role |
|------|------|
| `LOVE_VERSION` | Engine pin (`11.5`) |
| `LOVE_VERSION` | Engine pin (`12.0`) |
| `overlays/love-ios.plist` | Portrait-only Info.plist + display name **Pokemon Red** (copied over the upstream plist every build) |
| `love-src/` | Downloaded `love-11.5-ios-source` tree (**gitignored**, do not commit) |
| `cache/` | Downloaded zips (**gitignored**) |
| `love-src/` | Downloaded LÖVE 12.0 source tree (**gitignored**, do not commit) |
| `cache/` | Temporary source and dependency checkout data (**gitignored**) |
| `build/` | `xcodebuild` derived data (**gitignored**) |
Game payload lands at:
@@ -94,28 +91,10 @@ and is fused into the built `.app` (LÖVE auto-runs any bundled `*.love`).
## Apple libraries dependency
The official `love-11.5-ios-source.zip` already ships prebuilt iOS
xcframeworks under `platform/xcode/ios/libraries/` (SDL2, LuaJIT, freetype,
ogg, vorbis, theora, modplug).
If that folder is missing or incomplete (e.g. you cloned sources without
libs), download the matching prebuilts and install them:
```bash
curl -fL -o mobile/ios/cache/love-11.5-apple-libraries.zip \
https://github.com/love2d/love/releases/download/11.5/love-11.5-apple-libraries.zip
unzip -q mobile/ios/cache/love-11.5-apple-libraries.zip -d mobile/ios/cache
rm -rf mobile/ios/love-src/platform/xcode/ios/libraries
cp -R mobile/ios/cache/love-apple-dependencies/iOS/libraries \
mobile/ios/love-src/platform/xcode/ios/libraries
```
`scripts/build_ios.sh` checks for `libraries/SDL2.xcframework` and fails with
these instructions if it is absent.
Upstream also documents
[love-apple-dependencies](https://github.com/love2d/love-apple-dependencies)
as an alternate source of the same libraries.
`scripts/build_ios.sh --fetch` retrieves the matching iOS libraries and the
SDL3 framework from
[love-apple-dependencies](https://github.com/love2d/love-apple-dependencies).
Re-run it if either dependency directory is absent.
## App identity
@@ -134,7 +113,7 @@ so refreshing `love-src/` does not lose branding.
| Flag | Meaning |
|------|---------|
| *(default)* | Simulator, Debug, no signing |
| `--fetch` | Download/extract `love-11.5-ios-source.zip` if `love-src/` is missing |
| `--fetch` | Fetch the LÖVE 12.0 source tree and Apple dependencies if `love-src/` is missing |
| `--device` | Build against `iphoneos` instead of `iphonesimulator` |
| `--release` | `Release` configuration instead of `Debug` |
| `--package-only` | Zip `game.love` + apply plist overlay; skip `xcodebuild` |
@@ -147,5 +126,5 @@ Also: `scripts/build.sh ios` delegates here (`--release` is forwarded).
- iOS platform installed in Xcode (Settings → Platforms). `xcodebuild -showsdks`
should list `iphonesimulator` / `iphoneos`. A partial install can fail IB/xib
compiles with `iOS … Platform Not Installed` even when the SDK name appears.
- `love-src/` present (`--fetch` or manual unzip of `love-11.5-ios-source.zip`)
- iOS libraries under `love-src/platform/xcode/ios/libraries/` (see above)
- `love-src/` present (`--fetch`)
- iOS libraries under `love-src/platform/xcode/ios/libraries/` and SDL3 under `love-src/platform/xcode/shared/Frameworks/`
+1 -5
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Pokemon Red</string>
<string>gen1recomp</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
@@ -49,10 +49,6 @@
<false/>
<key>UILaunchStoryboardName</key>
<string>Launch Screen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>opengles-2</string>
</array>
<key>UIStatusBarHidden</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
+7 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Applies gen1recomp's iOS native-bridge patches to the fetched LÖVE 11.5
"""Applies gen1recomp's iOS native-bridge patches to the fetched LÖVE 12.0
source tree (mobile/ios/love-src/). Idempotent AND re-appliable: the first
run stashes a pristine `.orig` copy of every file it rewrites, and later
runs always start over from that copy so editing the patch content here
@@ -39,6 +39,7 @@ WRAP_INCLUDES = """
#ifdef LOVE_IOS
#include <objc/runtime.h>
#include <objc/message.h>
#include <string>
#include "filesystem/Filesystem.h"
#endif
""" % MARKER
@@ -53,8 +54,12 @@ WRAP_FUNCS = """
#ifdef LOVE_IOS
static const char *gr_saveDirectory()
{
static std::string saveDirectory;
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
return fs != nullptr ? fs->getSaveDirectory() : "";
if (fs == nullptr)
return "";
saveDirectory = fs->getSaveDirectory();
return saveDirectory.c_str();
}
static int gr_callBridge(lua_State *L, const char *className,
+1 -1
View File
@@ -156,7 +156,7 @@ return function(mod)
caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST"
mod.save:set("caught_areas", caughtAreas())
end
Bag.add(self.game.save, ball, 1)
Bag.add(self.game.save, ball, 1, self.game.data)
self:say(reason == "area" and "This area already\nhas a captured POKéMON!"
or "You already have\nthis POKéMON family!")
return
+5
View File
@@ -35,6 +35,7 @@ TARGET="all"
NOTARY_PROFILE="notary-profile"
NOTARIZE=true
IOS_RELEASE=false
IOS_IPA=false
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
@@ -48,6 +49,7 @@ while [ $# -gt 0 ]; do
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
--no-notarize) NOTARIZE=false ;;
--release) IOS_RELEASE=true ;;
--ipa) IOS_IPA=true ;;
*) fail "unknown argument: $1" ;;
esac
shift
@@ -298,6 +300,9 @@ build_android() {
if [ "$VERSION_EXPLICIT" = true ]; then
args+=(--version "$VERSION")
fi
if [ "$IOS_IPA" = true ]; then
args+=(--ipa)
fi
"$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"}
}
+29 -3
View File
@@ -198,6 +198,10 @@ pack_game_love() {
rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save
# row opens it in-process, so it must be inside the archive (see build.sh).
# Deliberately NO fused mods: a mod inside game.love sits in the read-only
# APK, so the mod manager's Delete can't remove it and it reappears every
# launch. Pokewalker ships as an importable .zip instead, which gives it
# a real install/upgrade/delete lifecycle.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
@@ -290,10 +294,32 @@ require_android_sdk() {
# --------------------------------------------------------------- gradle
run_gradle() {
local task="assembleEmbedNoRecordDebug"
say "building APK ($task)"
local build_dir="$ANDROID_DIR"
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
# the project path ("Your APP_BUILD_SCRIPT points to an unknown file").
# When this checkout lives at a spaced path (e.g. "~/xCode Projects/..."),
# shadow the android tree to a space-free location and build there; the
# shadow persists across runs so gradle/ndk builds stay incremental.
case "$ANDROID_DIR" in
*" "*)
build_dir="${TMPDIR:-/tmp}/gen1recomp-android-shadow"
say "path contains spaces (ndk-build cannot handle them);"
say "shadow-building in: $build_dir"
mkdir -p "$build_dir"
rsync -a --delete \
--exclude=".gradle" --exclude="app/build" --exclude="love/build" \
--exclude="local.properties" \
"$ANDROID_DIR/" "$build_dir/"
if [ -f "$ANDROID_DIR/local.properties" ]; then
cp "$ANDROID_DIR/local.properties" "$build_dir/local.properties"
fi
;;
esac
say "building APK ($task)"
if ! (
cd "$ANDROID_DIR"
cd "$build_dir"
./gradlew --no-daemon "$task"
); then
fail "gradle $task failed.
@@ -302,7 +328,7 @@ run_gradle() {
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
fi
local out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug"
if [ -d "$out_dir" ]; then
say "APK output:"
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
+81 -55
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 11.5's
# official iOS Xcode project (love-11.5-ios-source.zip).
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 12.0's
# iOS Xcode project.
#
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--install]
# [--version X.Y.Z] [--package-only]
@@ -12,13 +12,12 @@
# first connected iPhone/iPad (unlock it first)
# --release Release configuration
# --version X.Y.Z stamp MARKETING_VERSION / CURRENT_PROJECT_VERSION
# --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/
# --fetch Fetch LÖVE 12.0 sources and Apple dependencies into mobile/ios/love-src/
# --package-only Zip game.love + apply plist overlay; skip xcodebuild
#
# Prerequisites:
# - macOS + Xcode (xcodebuild)
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
#
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
# dist/ios/gen1recomp.ipa (device builds only)
@@ -50,21 +49,22 @@ DISPLAY_NAME="gen1recomp"
# capabilities like HealthKit are involved), so a per-team default
# lets anyone build without colliding with someone else's app
# 4. simulator: the project default (no App ID registration involved)
BUNDLE_ID="${GEN1_BUNDLE_ID:-}"
BUNDLE_ID="${GEN1_BUNDLE_ID:-com.theboisclub.gen1recomp}"
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
fi
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)"
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
APPLE_LIBS_ZIP="love-${LOVE_VERSION}-apple-libraries.zip"
IOS_SOURCE_URL="https://github.com/love2d/love/releases/download/${LOVE_VERSION}/${IOS_SOURCE_ZIP}"
APPLE_LIBS_URL="https://github.com/love2d/love/releases/download/${LOVE_VERSION}/${APPLE_LIBS_ZIP}"
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 12.0)"
LOVE_SOURCE_REF="${LOVE_SOURCE_REF:-main}"
APPLE_DEPENDENCIES_REF="${APPLE_DEPENDENCIES_REF:-main}"
LOVE_SOURCE_REPO="https://github.com/love2d/love.git"
APPLE_DEPENDENCIES_REPO="https://github.com/love2d/love-apple-dependencies.git"
FETCH=false
DEVICE=false
RELEASE=false
PACKAGE_ONLY=false
INSTALL=false
CREATE_IPA=false
# Last resort for an incomplete source export, mirroring build_android.sh.
MANIFEST_BASE_URL="${MANIFEST_BASE_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main}"
MANIFESTS=""
@@ -82,6 +82,7 @@ while [ $# -gt 0 ]; do
--release) RELEASE=true ;;
--package-only) PACKAGE_ONLY=true ;;
--install) INSTALL=true ;;
--ipa) CREATE_IPA=true ;;
--version) VERSION="$2"; shift ;;
-h|--help)
sed -n '2,24p' "$0"
@@ -92,6 +93,10 @@ while [ $# -gt 0 ]; do
shift
done
if $CREATE_IPA; then
DEVICE=true
fi
VERSION_CODE=""
if [ -n "$VERSION" ]; then
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
@@ -154,25 +159,19 @@ fi
# --------------------------------------------------------------- fetch love-src
fetch_love_ios() {
mkdir -p "$CACHE"
local zip_path="$CACHE/$IOS_SOURCE_ZIP"
if [ ! -f "$zip_path" ]; then
say "downloading $IOS_SOURCE_ZIP (LÖVE $LOVE_VERSION iOS sources)"
curl -fL --progress-bar "$IOS_SOURCE_URL" -o "$zip_path" \
|| fail "download failed: $IOS_SOURCE_URL"
else
say "using cached $zip_path"
fi
say "extracting into $LOVE_SRC"
rm -rf "$LOVE_SRC"
local tmp
tmp="$(mktemp -d "$CACHE/extract.XXXXXX")"
unzip -q "$zip_path" -d "$tmp"
# Zip root is love-<version>-ios-source/
local extracted
extracted="$(find "$tmp" -maxdepth 1 -mindepth 1 -type d ! -name '__MACOSX' | head -1)"
[ -n "$extracted" ] || fail "unexpected layout inside $IOS_SOURCE_ZIP"
mv "$extracted" "$LOVE_SRC"
say "fetching LÖVE $LOVE_VERSION sources ($LOVE_SOURCE_REF)"
git clone --depth 1 --branch "$LOVE_SOURCE_REF" "$LOVE_SOURCE_REPO" "$tmp/love" \
|| fail "failed to fetch LÖVE sources from $LOVE_SOURCE_REPO"
say "fetching Apple dependencies ($APPLE_DEPENDENCIES_REF)"
git clone --depth 1 --branch "$APPLE_DEPENDENCIES_REF" "$APPLE_DEPENDENCIES_REPO" "$tmp/dependencies" \
|| fail "failed to fetch Apple dependencies from $APPLE_DEPENDENCIES_REPO"
rm -rf "$LOVE_SRC"
mv "$tmp/love" "$LOVE_SRC"
mkdir -p "$LIBS_DIR" "$XCODE_DIR/shared"
cp -R "$tmp/dependencies/iOS/libraries/." "$LIBS_DIR"
cp -R "$tmp/dependencies/shared/." "$XCODE_DIR/shared"
rm -rf "$tmp"
say "love-src ready (LÖVE $LOVE_VERSION)"
}
@@ -182,18 +181,12 @@ if [ ! -d "$XCODE_DIR/love.xcodeproj" ]; then
fetch_love_ios
else
fail "LÖVE $LOVE_VERSION iOS sources not found at mobile/ios/love-src/.
Fetch them (documented download of love-${LOVE_VERSION}-ios-source.zip):
Fetch them:
scripts/build_ios.sh --fetch
Or manually:
mkdir -p mobile/ios/cache
curl -fL -o mobile/ios/cache/$IOS_SOURCE_ZIP \\
$IOS_SOURCE_URL
unzip -q mobile/ios/cache/$IOS_SOURCE_ZIP -d mobile/ios/cache
mv mobile/ios/cache/love-${LOVE_VERSION}-ios-source mobile/ios/love-src
See mobile/ios/README.md."
fi
elif $FETCH; then
say "love-src already present; skipping download (delete mobile/ios/love-src to refresh)"
say "love-src already present; skipping fetch (delete mobile/ios/love-src to refresh)"
fi
[ -d "$XCODE_DIR/love.xcodeproj" ] \
@@ -201,25 +194,16 @@ fi
# --------------------------------------------------------------- apple libraries
require_ios_libraries() {
if [ -d "$LIBS_DIR/SDL2.xcframework" ]; then
if [ -d "$LIBS_DIR/SDL2.xcframework" ] && [ -d "$XCODE_DIR/shared/Frameworks/SDL3.xcframework" ]; then
return 0
fi
fail "prebuilt iOS libraries missing at:
$LIBS_DIR
love-ios expects SDL2.xcframework (and friends) there.
and shared/Frameworks.
The official love-${LOVE_VERSION}-ios-source.zip normally includes them.
If they are absent, install love-${LOVE_VERSION}-apple-libraries.zip:
Re-fetch the LÖVE $LOVE_VERSION source tree and its Apple dependencies:
mkdir -p mobile/ios/cache
curl -fL -o mobile/ios/cache/$APPLE_LIBS_ZIP \\
$APPLE_LIBS_URL
unzip -q mobile/ios/cache/$APPLE_LIBS_ZIP -d mobile/ios/cache
rm -rf mobile/ios/love-src/platform/xcode/ios/libraries
cp -R mobile/ios/cache/love-apple-dependencies/iOS/libraries \\
mobile/ios/love-src/platform/xcode/ios/libraries
See mobile/ios/README.md (Apple libraries dependency)."
scripts/build_ios.sh --fetch"
}
require_ios_libraries
@@ -232,6 +216,30 @@ 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
@@ -326,8 +334,9 @@ pack_game_love() {
# 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 tools/save-editor/App.lua tools/save-editor/Kit.lua \
tools/save-editor/panels/Party.lua $MANIFESTS; do
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
@@ -534,11 +543,27 @@ run_xcodebuild() {
say "xcodebuild love-ios ($config / $sdk)"
set +e
(
cd "$XCODE_DIR"
xcodebuild "${args[@]}"
)
local xc_status=$?
local xc_status
if command -v xcbeautify >/dev/null 2>&1; then
(
cd "$XCODE_DIR"
xcodebuild "${args[@]}"
) 2>&1 | xcbeautify
local pipeline_status=("${PIPESTATUS[@]}")
local xcode_status=${pipeline_status[0]}
local beautify_status=${pipeline_status[1]}
if [ "$xcode_status" -ne 0 ]; then
xc_status=$xcode_status
else
xc_status=$beautify_status
fi
else
(
cd "$XCODE_DIR"
xcodebuild "${args[@]}"
)
xc_status=$?
fi
set -e
if [ "$xc_status" -ne 0 ]; then
fail "xcodebuild failed (exit $xc_status).
@@ -640,6 +665,7 @@ 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
+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
+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,
+27
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)
+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
+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
+1 -1
View File
@@ -1058,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({
+207 -66
View File
@@ -1,6 +1,7 @@
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
@@ -60,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 = {
@@ -636,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.
@@ -817,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
@@ -1003,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
@@ -1017,7 +1043,6 @@ function RomImporter:chooseMod()
else
self.pickPending = true
self.pickTimer = 0
self.pickElapsed = 0
end
return
end
@@ -1065,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
@@ -1082,7 +1117,6 @@ function RomImporter:chooseSaveImport(version)
else
self.pickPending = true
self.pickTimer = 0
self.pickElapsed = 0
end
return
end
@@ -1118,10 +1152,9 @@ 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.pickElapsed = 0
self.saveNotice[version] = { ok = true,
text = "Pick where to save " .. suggested .. "..." }
else
@@ -1156,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
@@ -1177,7 +1218,6 @@ function RomImporter:choose(version)
else
self.pickPending = true
self.pickTimer = 0
self.pickElapsed = 0
end
return
end
@@ -1225,18 +1265,18 @@ end
-- 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).
--
-- Disarms after PICK_TIMEOUT so a cancelled picker (which delivers nothing,
-- ever) does not leave this scanning the save directory for the whole session.
local PICK_TIMEOUT = 120
-- 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.pickPending then return end
if self.workState == "working" then return end
self.pickElapsed = (self.pickElapsed or 0) + dt
if self.pickElapsed > PICK_TIMEOUT then
self.pickPending, self.pickElapsed = nil, nil
return
end
self.pickTimer = (self.pickTimer or 0) + dt
if self.pickTimer < 0.5 then return end
self.pickTimer = 0
@@ -1246,7 +1286,7 @@ function RomImporter:_pollPickedFiles(dt)
local pickError = love.filesystem.read("pick_error.txt")
if pickError then
love.filesystem.remove("pick_error.txt")
self.pickPending, self.pickElapsed = nil, nil
self.pickPending = nil
self.modNotice = { ok = false, text = pickError }
self.notice = { version = self.chooseVersion or "red",
status = "File import failed:", detail = pickError }
@@ -1263,7 +1303,7 @@ function RomImporter:_pollPickedFiles(dt)
end
end
if found then
self.pickPending, self.pickElapsed = nil, nil
self.pickPending = nil
self:focus(true)
end
end
@@ -1271,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()
@@ -1295,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
@@ -1315,6 +1386,7 @@ function RomImporter:_cycleTab(delta)
self._slotPress = nil
self._modPress = nil
self._findSearchFocus = false
self:_disarmTextInput()
end
function RomImporter:_updatePadCursor(dt)
@@ -1344,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
@@ -1726,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
@@ -1745,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
@@ -1770,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 } }
@@ -1787,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
@@ -1810,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
@@ -1836,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 },
@@ -1858,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
@@ -1884,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.
@@ -1914,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
@@ -1925,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.
@@ -2103,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
@@ -2139,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)
@@ -2185,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)
@@ -2231,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"),
@@ -2241,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
@@ -2249,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))
@@ -2291,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))
@@ -2336,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))
@@ -2393,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))
@@ -2402,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)
@@ -2509,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
@@ -2569,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
@@ -2676,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
@@ -2797,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
@@ -2829,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
@@ -2845,6 +2948,7 @@ function RomImporter:keypressed(key)
self:_commitRename()
elseif key == "escape" then
self._rename = nil
self:_disarmTextInput()
end
return
end
@@ -2855,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
@@ -2885,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
@@ -3438,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
@@ -3445,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
@@ -3472,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)
@@ -3623,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,
@@ -4515,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
+50
View File
@@ -86,8 +86,58 @@ local function mergeConflictLists(conflicts, incompatible)
return out
end
-- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs,
-- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises
-- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a
-- panel may draw must be scrubbed here -- the one place every mod manifest
-- passes through -- or a single mangled description crashes the whole MODS
-- panel instead of misrendering one card.
local function scrubUtf8(s)
if type(s) ~= "string" then return s end
s = s:gsub("^\239\187\191", "")
local out, i, n = {}, 1, #s
while i <= n do
local b = s:byte(i)
local len
if b < 0x80 then len = 1
elseif b >= 0xC2 and b <= 0xDF then len = 2
elseif b >= 0xE0 and b <= 0xEF then len = 3
elseif b >= 0xF0 and b <= 0xF4 then len = 4
end
local ok = len ~= nil and i + len - 1 <= n
if ok and len > 1 then
for j = i + 1, i + len - 1 do
local c = s:byte(j)
if c < 0x80 or c > 0xBF then ok = false; break end
end
if ok then
-- boundary lead bytes narrow their second byte: no overlongs
-- (E0/F0), no surrogates (ED), nothing past U+10FFFF (F4)
local b2 = s:byte(i + 1)
if (b == 0xE0 and b2 < 0xA0) or (b == 0xED and b2 > 0x9F)
or (b == 0xF0 and b2 < 0x90) or (b == 0xF4 and b2 > 0x8F) then
ok = false
end
end
end
if ok then
out[#out + 1] = s:sub(i, i + len - 1)
i = i + len
else
i = i + 1
end
end
return table.concat(out)
end
function Manifest.validate(raw, path)
assert(type(raw) == "table", "manifest must be an object")
-- scrubbed in place so every later reader agrees, including the launcher's
-- badge derivation, which reads raw.category rather than the validated copy
raw.name = scrubUtf8(raw.name)
raw.version = scrubUtf8(raw.version)
raw.description = scrubUtf8(raw.description)
raw.category = scrubUtf8(raw.category)
assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"),
"manifest id must contain only letters, numbers, _ or -")
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
+6 -4
View File
@@ -1186,11 +1186,13 @@ R.field = {
nest = f.opt(f.any) },
flyOrder = f.list(f.str),
-- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle
-- back pic, the catch tutorial's old man, and the front pic the intro,
-- trainer card and Hall of Fame share. Every key is optional so a
-- conversion can replace one pic and inherit the rest.
-- back pic, the catch tutorial's old man, Yellow's PROF.OAK variant of
-- it (#557), and the front pic the intro, trainer card and Hall of Fame
-- share. Every key is optional so a conversion can replace one pic and
-- inherit the rest.
playerPics = f.rec{
back = f.opt(f.str), demoBack = f.opt(f.str), front = f.opt(f.str) },
back = f.opt(f.str), demoBack = f.opt(f.str),
oakBack = f.opt(f.str), front = f.opt(f.str) },
-- the new-game and boot config a total conversion replaces
boot = f.rec{
startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)),
+13 -1
View File
@@ -46,20 +46,32 @@ end
-- opts.kind: "battle" | "intro" | "trainer_card" | "hof"
-- opts.demo: the catch tutorial, where the old man fights in the player's
-- place and stands in for the back pic
-- opts.oakDemo: the Yellow variant of that demo (BATTLE_TYPE_PIKACHU), where
-- PROF.OAK fights in the player's place behind his own back pic
-- opts.battle: the live battle, for kind == "battle"
-- Returns path, trueColor.
function Sprites.playerPath(data, side, opts)
opts = opts or {}
side = side == "back" and "back" or "front"
-- one key per pic, so a conversion can replace the back and inherit the
-- rest; fieldValue folds data.field over FieldDefaults per key
-- rest; fieldValue folds data.field over FieldDefaults per key. The two
-- demo keys mirror LoadPlayerBackPic's wBattleType branch (#557).
local key = side == "front" and "front"
or (opts.oakDemo and "oakBack")
or (opts.demo and "demoBack" or "back")
local path = FieldDefaults.fieldValue(data, "playerPics", key)
-- ProfOakPicBack is a Yellow-only rip, so a cache imported before it
-- existed has no file there; fall back to the old man rather than hand a
-- missing path to getImage (#557)
if key == "oakBack" and path
and not require("src.render.Assets").exists(path) then
path = FieldDefaults.fieldValue(data, "playerPics", "demoBack")
end
local ctx = {
side = side,
kind = opts.kind or "battle",
demo = opts.demo and true or false,
oakDemo = opts.oakDemo and true or false,
battle = opts.battle,
trueColor = false,
data = data,
+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"
+76 -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,12 @@ function Renderer:endFrame(zones, worldZones)
-- runs, so dialogs, menus and the HUD sit on top as usual.
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
local loveMajor = love.getVersion()
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY)
else
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
end
love.graphics.setScissor()
-- the screen-space overlays the flat path draws over its composite
local fade = self.worldFadeAlpha
+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
+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
+11
View File
@@ -448,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()
+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 },
+77 -13
View File
@@ -1155,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
@@ -1803,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
@@ -1891,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))
@@ -2423,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
@@ -2558,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
@@ -2586,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
@@ -2607,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
@@ -3829,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
@@ -3837,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",
@@ -4568,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
+31 -8
View File
@@ -518,9 +518,12 @@ end
-- pikachu_emotions.asm): pick a scripted emotion, then play its bubble
-- and voiced PCM clip, and raise the framed Pikachu picture the original
-- puts over the map (pikaemotion_pikapic -> pikachu_pic_animation.asm
-- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. The
-- per-emotion animation frames (gfx/pikachu/unknown_*) are not extracted,
-- so the front pic stands in for all twenty of them (#407).
-- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. Each
-- script's BASE 5x5 frame is ripped as pikachu/pikapic_N.png (#561); the
-- pikaframe overlays it alternates with are a second full-body pose out of
-- the same blob and are still unported, so picLift below stands in for
-- their motion and the battle front pic covers caches built before the
-- rip (#407).
-- ---------------------------------------------------------------------
-- PikachuEmotionTable, reduced to each entry's bubble + pikaemotion_pcm
@@ -715,9 +718,17 @@ function PikachuFollower.talk(game, ow, npc, done)
-- 40x40 front pic is the size of PikaAnimTilemap_1's 5x5 base frame;
-- Sprites.path keeps a mod's replacement skin in play.
local Sprites = require("src.pokemon.Sprites")
local pic = Sprites.path(game.data, "PIKACHU", "front",
{ kind = "overworld" })
local anim = PIKAPIC[PIKAPIC_SCRIPT[emotion] or emotion] or PIKAPIC[1]
-- The chosen script's own base frame (its first pikapic_loadgfx, ripped as
-- pikachu/pikapic_N.png). Red/Blue have no such art and Yellow caches
-- built before the rip do not carry it, so both fall back to the battle
-- front pic that stood in for every script before (#561).
local script = PIKAPIC_SCRIPT[emotion] or emotion
local pic = "assets/generated/pikachu/pikapic_" .. script .. ".png"
if not require("src.render.Assets").exists(pic) then
pic = Sprites.path(game.data, "PIKACHU", "front",
{ kind = "overworld" })
end
local anim = PIKAPIC[script] or PIKAPIC[1]
local hold = anim.dur * PIKAPIC_TICK
ow.emote = {
npc = npc, frames = hold, bubble = bi or false, pikaPic = pic,
@@ -790,10 +801,22 @@ function PikachuFollower.onBillEnteredMachine(game, ow)
if not (GameVersion.isYellow() and ow.pikachuBillsScene) then return end
local npc = findFollower(ow)
if not npc then return end
-- BillsHouseScript3 (pokeyellow scripts/BillsHouse.asm:100-115). The two
-- movement tables are named the wrong way round in the cartridge source:
-- hl is seeded with ..._EnterCellSeparatorDown, then
-- and a ; cp SPRITE_FACING_DOWN (SPRITE_FACING_DOWN is 0)
-- jr nz, .applyPikachuMovement
-- keeps that seeded table when the player is NOT facing down, and only the
-- fallthrough -- facing down -- swaps in ..._EnterCellSeparatorNotDown.
-- So facing down takes the long way round the cell separator and every
-- other facing walks straight up. Following the label names instead of the
-- branch inverts the scene (#455).
local steps = ow.player.facing == "down"
and { { "up", 3 } }
or { { "up", 1 }, { "left", 1 }, { "up", 2 }, { "right", 1 } }
and { { "up", 1 }, { "left", 1 }, { "up", 2 }, { "right", 1 } }
or { { "up", 3 } }
movePikachu(ow, npc, steps, function()
-- PIKAMOVEMENT_LOOK_UP closes the detour table before the bubble
npc.facing = "up"
billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE")
end)
end
+43 -7
View File
@@ -15,11 +15,21 @@ local STEP_FRAMES = 16
-- a turn in place blocks movement for the one extra OverworldLoop pass the
-- original spends after a direction change: .handleDirectionButtonPress ends
-- `jp OverworldLoop` (home/overworld.asm), and OverworldLoop burns two
-- DelayFrame calls before the next JoypadOverworld, so the sample that can
-- commit to a step lands exactly 2 fixed steps after the turn -- the same
-- 2-frames-per-iteration cadence that makes STEP_FRAMES 16 above
-- (wWalkCounter = 8, 2px per AdvancePlayerSprite) (#415)
local TURN_FRAMES = 2
-- DelayFrame calls before the next JoypadOverworld, so the step can only
-- commit at the following poll -- the same 2-frames-per-iteration cadence
-- that makes STEP_FRAMES 16 above (wWalkCounter = 8, 2px per
-- AdvancePlayerSprite).
-- Those polls sit on a 2-frame grid, so the hardware samples a press 0 or 1
-- frames after the d-pad physically goes down and the release deadline lands
-- 2 or 3 frames after that. We sample on the frame the button goes down
-- with none of that poll latency, so a flat 2 handed every tap the tightest
-- case the original could produce; 4 covers the grid instead of
-- undercutting it (#415).
local TURN_FRAMES = 4
-- The on-screen d-pad cannot produce a 60ms tap: a finger press and release
-- run well past it even before the OS batches the touch events, so the
-- overlay gets a longer window than a physical pad (#415).
local TOUCH_TURN_FRAMES = 8
function Player.new(data, cx, cy, facing)
local self = setmetatable({}, Player)
@@ -67,6 +77,11 @@ function Player.new(data, cx, cy, facing)
self.progress = 0
self.stepFlip = false
self.turnTimer = 0
-- wCheckFor180DegreeTurn (home/overworld.asm): the original only lets a
-- turn in place happen on a poll whose previous pass found no direction
-- held. It starts armed, tryMove spends it, and OverworldState:handleInput
-- re-arms it from a standstill.
self.turnArmed = true
self.inputLocked = false
return self
end
@@ -75,14 +90,35 @@ function Player:position()
return self.cellX, self.cellY
end
-- How long a fresh turn holds the step off for; see TURN_FRAMES. The
-- overlay is detected per source rather than by whether the touch controls
-- are on screen, so a phone with a controller attached still gets the
-- physical pad's window (Input:isTouchDown, src/core/Input.lua).
function Player:turnWindow()
local frames = self.turnFrames or TURN_FRAMES
local input = require("src.core.Game").input
if input and input.isTouchDown and input:isTouchDown(self.facing) then
return math.max(frames, TOUCH_TURN_FRAMES)
end
return frames
end
-- Attempt to start a step; returns "moved"|"turned"|"blocked"|nil.
function Player:tryMove(dir, map, entities)
if self.moving or self.inputLocked then return nil end
if self.facing ~= dir then
self.facing = dir
self.turnTimer = self.turnFrames or TURN_FRAMES
self.bumpFrames = nil -- turning to a new facing ends any wall-bonk cycle
return "turned"
-- .handleDirectionButtonPress only reaches the turn while
-- wCheckFor180DegreeTurn is still set, and .noDirectionButtonsPressed is
-- the one place that sets it (home/overworld.asm), so a facing change
-- made without the d-pad ever coming up steps straight away rather than
-- paying the turn delay at every corner (#415)
if self.turnArmed then
self.turnArmed = false
self.turnTimer = self:turnWindow()
return "turned"
end
end
if self.turnTimer > 0 then return nil end
local ok, why = Collision.canMove(map, entities, self, dir)
+135
View File
@@ -0,0 +1,135 @@
-- The Cerulean badge man's list needs a CANCEL row (#569).
-- CeruleanBadgeHouseMiddleAgedManText loops DisplayListMenuID over
-- .BadgeItemList and takes `jr c, .done` out (pokered
-- scripts/CeruleanBadgeHouse.asm); PrintListMenuEntries prints
-- ListMenuCancelText at the list's $FF terminator, so CANCEL is a real row
-- and choosing it sets carry the same way B does.
-- POKEPORT_DRIVER=tests/drivers/badge_cancel_bug569_test.lua POKEPORT_IDENTITY=bug569 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local ListMenu = require("src.ui.ListMenu")
local TextBox = require("src.render.TextBox")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- pokered data/maps/objects/CeruleanBadgeHouse.asm: the middle-aged man is
-- object_event 5, 3, STAY, so he never wanders off the tile; the floor to
-- his west is the natural approach.
local MAP = "CERULEAN_BADGE_HOUSE"
local NPC = "CERULEANBADGEHOUSE_MIDDLE_AGED_MAN"
local STAND = { x = 4, y = 3, facing = "right" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- a renamed or unextracted text label reads on screen exactly like the
-- missing row did: nothing happens
local t = game.data.text
for _, key in ipairs({
"_CeruleanBadgeHouseMiddleAgedManText",
"_CeruleanBadgeHouseMiddleAgedManWhichBadgeText",
"_CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText",
"_CeruleanBadgeHouseBoulderBadgeText",
}) do
check(key .. " resolves to a string", type(t[key]) == "string")
end
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local function manIn(ow)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == NPC then return n end
end
return nil
end
local function facingTheMan()
local ow = game.overworld
local man = ow and manIn(ow)
if not man then return false end
local fx, fy = ow.player:facingCell()
return ow:npcAtCell(fx, fy) == man
end
local ow = game.overworld
local man = ow and manIn(ow)
check("the badge man is on " .. MAP, man ~= nil)
if man and not facingTheMan() then
-- a map edit moved him: take any free walkable neighbour instead.
-- {dx, dy, facing} is the offset from him to the stand cell plus the
-- direction that looks back, so +1 on x means facing left.
local sides = {
{ -1, 0, "right" }, { 1, 0, "left" }, { 0, 1, "up" }, { 0, -1, "down" },
}
for _, s in ipairs(sides) do
local cx, cy = man.cellX + s[1], man.cellY + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log("approach cell is blocked, standing on", cx, cy, "facing", s[3])
U.teleport(game, MAP, cx, cy, s[3])
U.wait(10)
break
end
end
end
check("player is standing in front of the badge man", facingTheMan())
-- talk, then mash through the greeting and the "Which badge?" prompt
U.tap(game, "a")
U.wait(20)
-- the greeting runs three pages before the "Which badge?" prompt, so give
-- the mash room; an A landing mid-type only speeds that page up
local list
for _ = 1, 60 do
local top = game.stack:top()
if getmetatable(top) == ListMenu then list = top break end
if getmetatable(top) == TextBox then U.tap(game, "a") end
U.wait(10)
end
check("talking to him opened the badge list", list ~= nil)
if list then
local labels = {}
for i, item in ipairs(list.items) do labels[i] = item.label end
U.log("badge list rows:", table.concat(labels, " "))
check("the list has the eight badges plus one more row", #list.items == 9)
local last = list.items[#list.items]
check("the last row is CANCEL", last.label == "CANCEL")
check("the CANCEL row carries no badge to describe", last.value == nil)
-- scroll it into view so the screenshot shows the row, not just the data
for _ = 1, #list.items do
if list.index == #list.items then break end
U.tap(game, "down")
U.wait(3)
end
check("cursor reached CANCEL", list.index == #list.items)
check("badge list screenshot",
U.shot(game, SHOT_DIR .. "/bug569_badge_list.png"))
U.tap(game, "a")
U.wait(25)
local top = game.stack:top()
check("CANCEL closed the list", getmetatable(top) ~= ListMenu)
check("CANCEL printed the goodbye line, like B does",
getmetatable(top) == TextBox)
check("goodbye screenshot",
U.shot(game, SHOT_DIR .. "/bug569_after_cancel.png"))
U.tap(game, "a")
U.wait(25)
end
U.log("The goodbye line has been dismissed; press A on the man to run the")
U.log("list again. CANCEL should be the ninth row under EARTHBADGE and")
U.log("should close the whole conversation with \"Visit any time\" -- the")
U.log("near miss is a CANCEL that only closes the list and drops you back")
U.log("on the \"Which badge?\" prompt. The badge rows above it are the")
U.log("control: each prints its description and returns to the list.")
U.log("Screenshots are under " .. SHOT_DIR)
while true do
coroutine.yield()
end
end
+196
View File
@@ -0,0 +1,196 @@
-- Ear check: cycling COLORS with 2 during a battle keeps the battle song (#484).
-- The COLORS change rebuilds the live map's atlas through reloadMap, which used
-- to re-enter the map and fire PlayMapMusic; ReloadMapData (home/reload_tiles.asm)
-- only re-reads the view and tile patterns, map music comes from LoadMapData
-- alone (home/overworld.asm). The invariants are in tests/engine/battle_colors_bug484.lua.
-- POKEPORT_DRIVER=tests/drivers/battle_colors_bug484_test.lua POKEPORT_IDENTITY=bug484 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
-- No POKEPORT_SPEED: it scales the logic clock only while audio runs on its own
-- real-time accumulator in Game:update, so it desyncs the ordering being judged.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Music = require("src.core.Music")
local PaletteFX = require("src.render.PaletteFX")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
-- pokered data/maps/objects/Route1.asm puts both youngsters at (5,24) and
-- (15,13) and the sign at (9,27), so the top of the road is empty; the
-- battle is pushed straight in, the cell is only somewhere to stand.
local MAP = "ROUTE_1"
local STAND = { x = 5, y = 6, facing = "down" }
local PARTY = { { "BULBASAUR", 12 }, { "PIDGEOTTO", 18 } }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- what the ear cannot check -----------------------------------------
local opts = game.save.options or {}
local musicVol = opts.musicVol or 7
local sfxVol = opts.sfxVol or 7
if musicVol == 0 then
U.log("FAIL MUSIC volume is 0. Everything below is about which song is")
U.log(" playing, and at 0 there is no song. Set MUSIC to 7 in OPTION")
U.log(" and run this again, or the whole check is worthless.")
end
if sfxVol == 0 then
U.log("FAIL SFX volume is 0, so the battle menu's own click is gone too")
U.log(" and there is no way to tell a dead audio path from a fixed bug.")
U.log(" Set SFX to 7 in OPTION.")
end
check(("MUSIC volume %d, SFX volume %d"):format(musicVol, sfxVol),
musicVol > 0 and sfxVol > 0)
local battleSongs = (game.data.audio or {}).battle or {}
local mapSongs = (game.data.audio or {}).mapSongs or {}
check(MAP .. " has a map theme to wrongly cut in (" ..
tostring(mapSongs[MAP]) .. ")", mapSongs[MAP] ~= nil)
check("the wild battle theme exists (" .. tostring(battleSongs.wild) .. ")",
battleSongs.wild ~= nil)
check("they are different songs, so the swap would be audible",
battleSongs.wild ~= nil and battleSongs.wild ~= mapSongs[MAP])
U.log(" COLORS ladder:", table.concat(PaletteFX.MODES, ", "))
-- Every music cue in the run, forwarded so playback is untouched. A map
-- cue raised while a battle owns the screen is the bug, and it is the one
-- thing an ear can miss if the two songs happen to open alike.
local cues = {}
local realPlay, realPlayMap = Music.play, Music.playMap
Music.play = function(data, song, loop, ctx)
cues[#cues + 1] = { song = song, reason = ctx and ctx.reason or "direct" }
return realPlay(data, song, loop, ctx)
end
Music.playMap = function(data, mapId, onBike, surfing)
cues[#cues + 1] = { song = "(playMap)", reason = "map", mapId = mapId }
return realPlayMap(data, mapId, onBike, surfing)
end
local function mapCuesSince(mark)
local hits = {}
for i = mark + 1, #cues do
if cues[i].reason == "map" then hits[#hits + 1] = cues[i].song end
end
return hits
end
local function lastSong()
for i = #cues, 1, -1 do
if cues[i].song ~= "(playMap)" then return cues[i].song end
end
return nil
end
-- ---- get into a battle --------------------------------------------------
game.save.party = {}
for _, slot in ipairs(PARTY) do
table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2]))
end
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(20)
local ow = game.overworld
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
-- a map edit moved the road: any free cell on Route 1 will do, the
-- battle is pushed rather than walked into
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
local cx, cy = STAND.x + d[1], STAND.y + d[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
cx, cy)
U.teleport(game, MAP, cx, cy, STAND.facing)
U.wait(20)
ow = game.overworld
break
end
end
end
check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
U.wait(60) -- let the route theme actually get going first
U.log(" route theme:", tostring(lastSong()))
local battle = BattleState.newWild(game, "RATTATA", 5)
battle.onFinish = function() end
ow:pushBattle(battle)
for _ = 1, 400 do
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
U.wait(1)
end
check("the battle reached the screen", game.stack:top() == battle)
for _ = 1, 120 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(6)
end
check("the battle reached its FIGHT/PKMN/ITEM/RUN menu", battle.phase == "menu")
local songInBattle = lastSong()
check("the battle theme is what is playing (" .. tostring(songInBattle) .. ")",
songInBattle == battleSongs.wild)
U.shot(game, DIR .. "/bug484_1_" .. tostring(PaletteFX.mode) .. ".png")
-- ---- press 2, for real --------------------------------------------------
local before = PaletteFX.mode
local mark = #cues
game:keypressed("2")
U.wait(30)
check("pressing 2 in a battle still changed COLORS ("
.. PaletteFX.modeLabel(before) .. " -> "
.. PaletteFX.modeLabel(PaletteFX.mode) .. ")",
PaletteFX.mode ~= before)
local strays = mapCuesSince(mark)
check("no map music cue came out of it (#484)", #strays == 0)
check("the battle theme is still the current song",
lastSong() == songInBattle)
U.shot(game, DIR .. "/bug484_2_" .. tostring(PaletteFX.mode) .. ".png")
-- the whole ladder, because a partial fix can leak on one mode only
local seen = { [before] = true, [PaletteFX.mode] = true }
local leaked = {}
for _ = 2, #PaletteFX.MODES do
local m = #cues
game:keypressed("2")
U.wait(20)
seen[PaletteFX.mode] = true
for _, s in ipairs(mapCuesSince(m)) do
leaked[#leaked + 1] = PaletteFX.modeLabel(PaletteFX.mode) .. " (" .. s .. ")"
end
end
local modes = 0
for _ in pairs(seen) do modes = modes + 1 end
check(("all %d COLORS modes were reachable from inside the battle"):format(modes),
modes == #PaletteFX.MODES)
check("no mode on the ladder started the route theme", #leaked == 0)
if #leaked > 0 then U.log(" leaked on:", table.concat(leaked, ", ")) end
check("still the battle theme after the full ladder",
lastSong() == songInBattle)
U.shot(game, DIR .. "/bug484_3_" .. tostring(PaletteFX.mode) .. ".png")
-- ---- over to you --------------------------------------------------------
U.log("You are at the battle menu with the wild theme running. Press 2 a few")
U.log("times: the wash behind the two pics, RATTATA's shading and the HP bars")
U.log("move mode to mode (shots 1 and 2 are one press apart), and the music")
U.log("underneath keeps going without a seam. #484 dropped Route 1's theme")
U.log("over it on the first press. The near miss to listen for is the battle")
U.log("theme restarting from its opening bar rather than being replaced, and")
U.log("the near miss to look for is nothing moving at all -- a hotkey switched")
U.log("off in battle is not a fix. A on the menu clicks, so that click is your")
U.log("proof the audio path is up at all.")
U.log("Shots: " .. DIR .. "/bug484_*.png")
-- keeps reporting after the hand-off: a map cue with a battle on top is the
-- bug whether or not the two songs are easy to tell apart by ear
local reported = #cues -- only what happens from the hand-off on
while true do
local top = game.stack:top()
local inBattle = top == battle or (top and top.battle == battle)
if inBattle and #cues > reported then
for i = reported + 1, #cues do
if cues[i].reason == "map" then
U.log("FAIL map music cue during the battle:", tostring(cues[i].song),
tostring(cues[i].mapId))
end
end
end
reported = #cues
coroutine.yield()
end
end
+186
View File
@@ -0,0 +1,186 @@
-- Look at the two boxes a test cannot judge: the bike shop's BICYCLE/CANCEL
-- price window (#568) and a Pokemon Center bench guy answering A (#488).
-- Oracle: scripts/BikeShop.asm BikeShopClerkText (TextBoxBorder 0,0 b=4 c=15
-- over BikeShopMenuText/BikeShopMenuPrice) and data/events/bench_guys.asm.
-- The logic halves are asserted in tests/parity_bike_shop_clerk_bug568.lua
-- and tests/parity_bench_guy_facing_bug488.lua.
-- POKEPORT_DRIVER=tests/drivers/bike_shop_bug568_test.lua POKEPORT_IDENTITY=bug568 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local TextBox = require("src.render.TextBox")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local failures = 0
local function check(label, ok)
if not ok then failures = failures + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function boxOnScreen()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return nil end
local lines = {}
for _, page in ipairs(top.pages or {}) do
for _, line in ipairs(page) do lines[#lines + 1] = line end
end
return top, table.concat(lines, " / ")
end
-- A missing string or a renamed label goes quiet in exactly the way both
-- bugs did, so resolve everything first and say so before anyone looks.
local t = game.data.text
for _, label in ipairs({ "_BikeShopClerkWelcomeText",
"_BikeShopClerkDoYouLikeItText", "_BikeShopCantAffordText",
"_BikeShopComeAgainText", "_VermilionPokecenterGuyText" }) do
check(label .. " resolves", type(t[label]) == "string" and t[label] ~= "")
end
check("BICYCLE is an item the window can name",
game.data.items.BICYCLE ~= nil)
local seat = (game.data.field.hiddenExtras.benchGuys.VERMILION_POKECENTER
or {})[1]
check("the Vermilion bench guy is in the generated data", seat ~= nil)
check("his seat is faced from the left (BenchGuyTextPointers)",
seat and seat.textFacing == "left")
if failures > 0 then
U.log(failures, "check(s) failed above; the screens below will not tell")
U.log("you anything the log has not already said.")
end
-- ------------------------------------------------------------- #488
-- data/events/hidden_events.asm parks the seat at (0,4); it is the bench
-- wall itself, so the player stands on the floor tile east of it. The
-- cell north of the seat is walkable too and is the control: in the
-- original, PrintBenchGuyText only answers a LEFT facing, so an A press
-- from up there does nothing, and that is correct.
local BENCH = { map = "VERMILION_POKECENTER", x = seat and seat.x or 0,
y = seat and seat.y or 4 }
U.teleport(game, BENCH.map, BENCH.x, BENCH.y - 1, "down")
U.wait(10)
U.tap(game, "a")
U.wait(20)
check("facing the seat from the north stays silent, like the original",
boxOnScreen() == nil)
-- the seat's own approach, with a fallback in case a map edit walls it in
local stand = { x = BENCH.x + 1, y = BENCH.y, facing = "left" }
if not game.overworld.map:isWalkableCell(stand.x, stand.y) then
for _, side in ipairs({ { -1, 0, "right" }, { 0, 1, "up" }, { 0, -1, "down" } }) do
local cx, cy = BENCH.x + side[1], BENCH.y + side[2]
if game.overworld.map:isWalkableCell(cx, cy) then
U.log("the floor east of the seat is gone; standing at", cx, cy)
stand = { x = cx, y = cy, facing = side[3] }
break
end
end
end
U.teleport(game, BENCH.map, stand.x, stand.y, stand.facing)
U.wait(10)
U.tap(game, "a")
U.wait(90) -- let the first page finish typing before the shot
local box, text = boxOnScreen()
check("A from the east opens the bench guy's box (#488)", box ~= nil)
if box then
U.log("bench guy reads:", text)
check("it is his line about no universally strong POKeMON",
text:find("universally", 1, true) ~= nil)
check("screenshot on disk",
U.shot(game, SHOT_DIR .. "/bug488_bench_guy.png"))
U.log("captured", SHOT_DIR .. "/bug488_bench_guy.png")
U.tap(game, "a")
U.wait(20)
end
-- ------------------------------------------------------------- #568
-- pokered data/maps/objects/BikeShop.asm: the clerk stands at (6,2)
-- behind the counter row, so the player talks across it from (6,4).
-- Clear the bike and the voucher so the talk takes the sales pitch.
game.save.inventory.BICYCLE = nil
game.save.inventory.BIKE_VOUCHER = nil
game.save.flags.EVENT_GOT_BICYCLE = nil
U.teleport(game, "BIKE_SHOP", 6, 4, "up")
U.wait(10)
local ow = game.overworld
local clerk
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == "BIKESHOP_CLERK" then clerk = n end
-- the middle-aged woman paces; park her so she cannot wander into shot
if n.def and n.def.name == "BIKESHOP_MIDDLE_AGED_WOMAN" then
n.frozen = true
end
end
check("the clerk is on the map", clerk ~= nil)
-- talking across a counter reads one cell further, so the stand cell is
-- two south of him; fall back to any free neighbour if the shop is redrawn
local function facingTheClerk()
local w = game.overworld
local fx, fy = w.player:facingCell()
if w:npcAtCell(fx, fy) == clerk then return true end
if not w.map:isCounterCell(fx, fy) then return false end
local Collision = require("src.world.Collision")
local bx, by = Collision.target(fx, fy, w.player.facing)
return w:npcAtCell(bx, by) == clerk
end
if clerk and not facingTheClerk() then
for _, side in ipairs({ { 0, 2, "up" }, { 0, 1, "up" }, { 0, -1, "down" },
{ 1, 0, "left" }, { -1, 0, "right" } }) do
local cx, cy = clerk.cellX + side[1], clerk.cellY + side[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log("(6,4) no longer reaches the counter; standing at", cx, cy)
U.teleport(game, "BIKE_SHOP", cx, cy, side[3])
U.wait(10)
break
end
end
end
check("the player is at the counter", facingTheClerk())
U.tap(game, "a")
U.wait(20)
local _, welcome = boxOnScreen()
check("A opens the welcome line", welcome ~= nil)
if welcome then U.log("clerk reads:", welcome) end
-- mash through the welcome box; the pitch box then types itself out and
-- pops on its own, handing the screen to the price window
local window
for _ = 1, 200 do
local top = game.stack:top()
if top ~= game.overworld and getmetatable(top) ~= TextBox then
window = top
break
end
U.tap(game, "a")
U.wait(4)
end
check("the price window is up and holding input (#568)", window ~= nil)
if window then
check("the cursor starts on BICYCLE", window.index == 1)
check("it kept the pitch line for the bottom box",
type(window.footer) == "table" and #window.footer > 0)
check("screenshot on disk",
U.shot(game, SHOT_DIR .. "/bug568_price_window.png"))
U.log("captured", SHOT_DIR .. "/bug568_price_window.png")
end
U.log("The window on screen is the shop menu: BICYCLE over CANCEL at the")
U.log("top left, \194\1651000000 beside the BICYCLE row, cursor on BICYCLE, and")
U.log("\"It's a cool BIKE! Do you want it?\" still in the bottom box under it.")
U.log("Input is live. Up and down move between the two rows and clamp -- the")
U.log("cursor must not wrap. A buys, B or CANCEL backs out; either way the")
U.log("window has to stay on screen behind the closing lines and only")
U.log("disappear with \"Come back again some time!\". A window that vanishes")
U.log("the moment you answer, or a bottom box that blanks while the menu is")
U.log("up, is the near miss to watch for. Talk to him again to replay it.")
U.log("The boy by the door is the control: he has no menu, just a line.")
while true do
coroutine.yield()
end
end
+216
View File
@@ -0,0 +1,216 @@
-- Manual check that the rival shoves Red off the EEVEE ball on the right
-- beat (#559). pokeyellow scripts/OaksLab.asm: OaksLabChoseStarterScript
-- queues DOWN + RIGHT x3, and .asm_1c564 polls until wNPCNumScriptedSteps
-- reads 1 before it simulates the PAD_RIGHT x2 -- so the push starts as the
-- rival steps onto the player's own tile, not when he leaves the table.
-- No POKEPORT_SPEED: the whole point is which frame the player starts on.
-- No POKEPORT_IDENTITY either -- a sandbox save dir has no Yellow cache and
-- boots stale Red data underneath a Yellow-looking run.
-- POKEPORT_DRIVER=tests/drivers/eevee_shove_bug559_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local GameVersion = require("src.core.GameVersion")
local MapScripts = require("src.script.MapScripts")
local Commands = require("src.script.Commands")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function idle()
while true do coroutine.yield() end
end
-- Red and Blue load data/scripts/oaks_lab.lua instead (three starter
-- balls, no shove at all), so every line below would fail for the wrong
-- reason on the Red cache.
if not check("running the Yellow cache (POKEPORT_VERSION=yellow)",
GameVersion.isYellow()) then
U.log("Yellow is the only version with an EEVEE ball. Re-run with")
U.log("POKEPORT_VERSION=yellow.")
idle()
end
-- pokeyellow data/maps/objects/OaksLab.asm: OAKSLAB_RIVAL (4,3),
-- OAKSLAB_EEVEE_POKE_BALL (7,3), OAKSLAB_OAK1 (5,2). The player talks to
-- the ball from (7,4), and that y == 4 is what selects the shove branch
-- in both the original (.asm_1c564 tests wYCoord) and the port.
local MAP = "OAKS_LAB"
local BALL = "OAKSLAB_EEVEE_POKE_BALL"
local RIVAL_INDEX = 1
local RIVAL_HOME = { x = 4, y = 3 }
local STAND = { x = 7, y = 4, facing = "up" }
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
-- data/scripts/init.lua picks oaks_lab_yellow over oaks_lab on the Yellow
-- cache; if the Red module won, the ball is one of three starters and
-- nothing below means anything. Asked after the teleport because the
-- registry only fills when OverworldState first requires it.
local labModule = require("data.scripts.oaks_lab_yellow")
local bound = MapScripts.talkScript(MAP, "TEXT_OAKSLAB_EEVEE_POKE_BALL")
U.log("the map dispatches a", type(bound), "for the ball")
check("the Yellow ball handler is the one bound to the map",
bound ~= nil and bound == labModule.talk.TEXT_OAKSLAB_EEVEE_POKE_BALL)
local ow = game.overworld
local ctx = { game = game, save = game.save, overworld = ow }
local function npcNamed(name)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == name then return n end
end
return nil
end
-- The scene the shove belongs to: Oak has asked the player to choose and
-- the rival is still standing at the table. Oak himself starts hidden on
-- this map, so put him back the way OaksLabScript does before its speech.
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_OAK_ASKED_TO_CHOOSE_MON = true
game.save.flags.EVENT_GOT_STARTER = nil
game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
Commands.show_object(ctx, MAP, "OAKSLAB_RIVAL")
Commands.show_object(ctx, MAP, "OAKSLAB_OAK1")
U.wait(2)
local rival = ow:npcByIndex(RIVAL_INDEX)
if rival and (rival.cellX ~= RIVAL_HOME.x or rival.cellY ~= RIVAL_HOME.y) then
-- he wanders nowhere on this map (STAY), but a converted save can drop
-- him mid-walk; pin him back on the table corner he starts from
U.log("rival was at", rival.cellX, rival.cellY, "- pinning him to",
RIVAL_HOME.x, RIVAL_HOME.y)
Commands.place_npc(ctx, RIVAL_INDEX, RIVAL_HOME.x, RIVAL_HOME.y, "down")
end
if not check("the rival is on the map at (4,3)",
rival ~= nil and rival.cellX == RIVAL_HOME.x
and rival.cellY == RIVAL_HOME.y) then
idle()
end
local ball = npcNamed(BALL)
if not check("the EEVEE ball object is on the map", ball ~= nil) then
U.log("Nothing to talk to. A save past EVENT_GOT_STARTER has already")
U.log("hidden it, and this scene cannot be replayed on that save.")
idle()
end
-- derive the talking cell from the ball rather than trusting (7,4): a
-- later map edit degrades to another free neighbour instead of parking
-- the player inside the table
local DIRS = { { "up", 0, 1 }, { "down", 0, -1 },
{ "left", 1, 0 }, { "right", -1, 0 } }
local function facingBall()
local fx, fy = ow.player:facingCell()
return ow:npcAtCell(fx, fy) == ball
end
if not facingBall() then
for _, d in ipairs(DIRS) do
local cx, cy = ball.cellX + d[2], ball.cellY + d[3]
if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy)
and not ow:npcAtCell(cx, cy) then
U.log("standing at", cx, cy, "facing", d[1], "instead")
U.teleport(game, MAP, cx, cy, d[1])
U.wait(6)
ow = game.overworld
ctx.overworld = ow
ball = npcNamed(BALL)
rival = ow:npcByIndex(RIVAL_INDEX)
break
end
end
end
check("player is facing the ball", facingBall())
local p = ow.player
U.log("player at", p.cellX, p.cellY, "facing", p.facing,
"| rival at", rival.cellX, rival.cellY,
"| ball at", ball.cellX, ball.cellY)
-- y == 4 is the branch under test; from anywhere else both the original
-- and the port just walk the rival over with no shove at all
check("the player is on the row that triggers the shove (y == 4)",
p.cellY == 4)
if U.shot(game, SHOT_DIR .. "/bug559_before.png") then
U.log("captured", SHOT_DIR .. "/bug559_before.png")
end
-- ---- talk to the ball and watch the two walks frame by frame ----
U.tap(game, "a")
local shoveFrame, rivalAtShove
local trace, lastCell = {}, nil
for f = 1, 900 do
local cell = rival.cellX .. "," .. rival.cellY
if cell ~= lastCell then
lastCell = cell
trace[#trace + 1] = "f" .. f .. " rival " .. cell
end
if not shoveFrame and ow.player.moving then
shoveFrame = f
rivalAtShove = { x = rival.cellX, y = rival.cellY }
end
if shoveFrame and not ow.player.moving and ow.player.cellX >= STAND.x + 2 then
break
end
U.wait(1)
end
U.log("rival trace:", table.concat(trace, " | "))
if not check("the player was pushed at all", shoveFrame ~= nil) then
U.log("The rival's walk ran but nothing moved the player, so the scene")
U.log("never reached move_player. Check the runner did not stall on the")
U.log("emote row.")
idle()
end
U.log("the push started on driver frame", shoveFrame, "with the rival at",
rivalAtShove.x, rivalAtShove.y)
-- The whole of #559: at the frame the player starts moving the rival must
-- already be alongside him on row 4, one step from the tile he is being
-- pushed off. Before the fix both walks started together and the rival
-- was still up at the table (4,3)/(4,4).
check("the rival had reached row 4 before the push", rivalAtShove.y == 4)
check("he was one step from the player's tile, not still at the table",
rivalAtShove.x >= ball.cellX - 1)
if U.shot(game, SHOT_DIR .. "/bug559_shove.png") then
U.log("captured", SHOT_DIR .. "/bug559_shove.png")
end
-- let the two walks settle, then the beat the script waits before it turns
-- the rival up to face the player (the row after move_player)
for _ = 1, 240 do
if not ow.player.moving and not rival.moving and rival.facing == "up" then
break
end
U.wait(1)
end
U.log("after the push: player at", ow.player.cellX, ow.player.cellY,
"| rival at", rival.cellX, rival.cellY, "facing", rival.facing)
check("the player ended two tiles right of where he stood",
ow.player.cellX == STAND.x + 2 and ow.player.cellY == STAND.y)
check("the rival ended on the ball's row-4 tile facing up",
rival.cellX == ball.cellX and rival.cellY == 4
and rival.facing == "up")
if U.shot(game, SHOT_DIR .. "/bug559_after.png") then
U.log("captured", SHOT_DIR .. "/bug559_after.png")
end
U.log("The shove has already happened; the box on screen is the rival")
U.log("gloating. Press A through his five lines to see the rest. Watch the")
U.log("shot pair: the rival walks down and across the table first, and Red")
U.log("only slides right as the rival arrives on top of him, both moving")
U.log("together for that last tile. The near miss to look for is Red")
U.log("stepping away early, with the rival still up at (4,3) or halfway")
U.log("along row 4 behind him -- that is #559 unchanged.")
U.log("Control case: on a save where Oak has not asked you to choose yet,")
U.log("the same A press only prints \"That's a POKe BALL\" and nobody")
U.log("moves, which is the handler working correctly.")
idle()
end
+175
View File
@@ -0,0 +1,175 @@
-- Manual check of the Silph Scope unveil in the POKEMON_TOWER_6F battle (#492).
-- PrintBeginningBattleText .isMarowak (engine/battle/common_text.asm:49-60)
-- enters disguised even with the scope and plays UnveiledGhostText + MarowakAnim
-- over the GHOST before "Wild MAROWAK appeared!"; the port used to open the
-- battle with MAROWAK already loaded. Sequencing half:
-- tests/engine/ghost_unveil_bug492.lua. Never under POKEPORT_SPEED -- the
-- unveil is the thing being timed.
-- POKEPORT_DRIVER=tests/drivers/ghost_unveil_bug492_test.lua POKEPORT_IDENTITY=bug492 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local pass, fail = 0, 0
local function check(label, ok)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- machine checks ----------------------------------------------------
-- A missing text key, a renamed asset and an unarmed branch all look like
-- the bug from outside: the battle just opens with MAROWAK on screen.
U.log("#492 Silph Scope unveil: machine checks")
check("SILPH_SCOPE is a real item", game.data.items.SILPH_SCOPE ~= nil)
check("MAROWAK is a real species", game.data.pokemon.MAROWAK ~= nil)
check("BattleState:makeUnveiledGhost exists",
type(BattleState.makeUnveiledGhost) == "function")
local unveil = game.data.text._UnveiledGhostText
check("_UnveiledGhostText extracted", type(unveil) == "string" and unveil ~= "")
if type(unveil) == "string" then
U.log("the unveil line reads:", (unveil:gsub("[\n\011\012]", " / ")))
end
check("_PokemonTower6FBeGoneText extracted",
type(game.data.text._PokemonTower6FBeGoneText) == "string")
-- the disguise pic resolves through the same override chain the battle uses
local Assets = require("src.render.Assets")
local GHOST_PIC = "assets/generated/battle/front/ghost.png"
check("the ghost front pic is in the cache",
Assets.exists(Assets.resolve(GHOST_PIC)))
local opts = game.save.options or {}
U.log("sfxVol", tostring(opts.sfxVol), "musicVol", tostring(opts.musicVol))
if opts.sfxVol == 0 or opts.musicVol == 0 then
U.log("a volume is at 0: raise it in OPTION before judging what you hear")
end
U.log("no intro cry here is CORRECT -- .isMarowak never reaches PlayCry.")
U.log("The CHANNELER at (12, 10) is the control: her GASTLY cries normally.")
-- ---- reach the trigger -------------------------------------------------
-- pokered scripts/PokemonTower6F.asm PokemonTower6FMarowakCoords:
-- dbmapcoord 10, 16. Row 17 is solid wall and (9, 16) is the stairwell
-- warp down to 5F, so the approach is from (10, 15) facing down.
local MAP = "POKEMON_TOWER_6F"
local TRIGGER = { x = 10, y = 16 }
local STAND = { x = 10, y = 15, facing = "down", step = "down" }
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
game.save.player.name = "RED"
game.save.inventory.SILPH_SCOPE = 1
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = nil
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local ow = game.overworld
check("the overworld is up on " .. MAP, ow ~= nil and ow.map ~= nil)
if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
-- a map edit moved the approach: take any walkable neighbour of the
-- trigger that is not the stairwell warp, and step back toward it.
-- {dx, dy, facing} is the offset from the trigger to the stand cell plus
-- the direction that walks back onto it.
local sides = { { 0, -1, "down" }, { 1, 0, "left" },
{ -1, 0, "right" }, { 0, 1, "up" } }
for _, s in ipairs(sides) do
local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow.map:warpAtCell(cx, cy) then
U.log(("(%d, %d) is blocked, approaching from"):format(STAND.x, STAND.y),
cx, cy, "stepping", s[3])
STAND = { x = cx, y = cy, facing = s[3], step = s[3] }
U.teleport(game, MAP, cx, cy, s[3])
U.wait(10)
ow = game.overworld
break
end
end
end
-- walk onto the trigger; MapScripts onStep fires on the completed step
U.hold(game, STAND.step, 20)
U.wait(20)
check("the Be-gone text opened", game.stack:top() ~= game.overworld)
-- ---- into the battle ---------------------------------------------------
local battle
for _ = 1, 300 do
local top = game.stack:top()
if getmetatable(top) == BattleState then battle = top break end
U.tap(game, "a")
U.wait(4)
end
check("the MAROWAK battle opened", battle ~= nil)
if not battle then
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
while true do coroutine.yield() end
end
battle.onFinish = battle.onFinish or function() end
check("the scope armed the unveil, not a plain wild battle",
battle.scopeReveal == true)
check("IsGhostBattle stays false with the scope in the bag", not battle.ghost)
check("the battle OPENED as the GHOST, not as MAROWAK (#492)",
battle.enemy and battle.enemy.name == "GHOST")
check("the real nick is held back for the unveil",
battle.ghostReal ~= nil and battle.ghostReal.name == "MAROWAK")
-- ---- watch the unveil --------------------------------------------------
-- A turns the "GHOST appeared!" and unveil boxes; stop pressing the moment
-- MarowakAnim arms so nothing mashes past "Wild MAROWAK appeared!".
for _ = 1, 600 do
if battle.ghostReveal then break end
U.tap(game, "a")
U.wait(4)
end
check("MarowakAnim started", battle.ghostReveal ~= nil)
local FLASH = BattleState.GHOST_FLASH_FRAMES
local FADE_OUT = BattleState.GHOST_FADE_OUT_FRAMES
local shotFlash, swapT, nameDuringFlash = false, nil, true
for _ = 1, 400 do
local gr = battle.ghostReveal
local t = gr and gr.t or nil
if t and t <= FLASH + FADE_OUT and battle.enemy.name ~= "GHOST" then
nameDuringFlash = false
end
if t and not swapT and battle.enemy.name ~= "GHOST" then swapT = t end
if t and t >= 25 and not shotFlash then
shotFlash = U.shot(game, DIR .. "/bug492_ghost_flashing.png")
end
if not gr and swapT then break end
U.wait(1)
end
check("the GHOST was still on screen through the flash and fade-out (#492)",
nameDuringFlash)
check(("the swap landed after the fade-out (frame %s of %d)")
:format(tostring(swapT), FLASH + FADE_OUT),
swapT ~= nil and swapT > FLASH + FADE_OUT)
check("the foe is MAROWAK once the animation finishes",
battle.enemy.name == "MAROWAK")
check("captured the flashing ghost", shotFlash)
-- the "Wild MAROWAK appeared!" box that closes .isMarowak
U.wait(30)
check("captured the unveiled MAROWAK",
U.shot(game, DIR .. "/bug492_marowak_revealed.png"))
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
-- ---- hand off ----------------------------------------------------------
U.log("The pad is yours in the battle the unveil just finished. What you")
U.log("should have seen: the box says the GHOST appeared, the SILPH SCOPE")
U.log("line follows, and only THEN does the ghost flash, dissolve and come")
U.log("back as MAROWAK before \"Wild MAROWAK appeared!\". The near miss to")
U.log("watch for is the sprite swapping early -- MAROWAK already standing")
U.log("there while the unveil text is still typing, which is #492 itself.")
U.log("To see it again: run from the battle (that steps you one cell right),")
U.log("walk back to (10, 15) and step down onto (10, 16). Beating it sets")
U.log("EVENT_BEAT_GHOST_MAROWAK and the trigger is spent for this save.")
U.log("Screenshots: " .. DIR .. "/bug492_*.png")
while true do
coroutine.yield()
end
end
+234
View File
@@ -0,0 +1,234 @@
-- Ear check: the menu click on backing out of POKéDEX / POKéMON / ITEM /
-- OPTION, on the first A on an item in ITEM, and on A over CANCEL in OPTION
-- (#570). HandleMenuInput_ (home/window.asm) replays SFX_PRESS_AB for the
-- PAD_A | PAD_B half of a menu's watched keys; DisplayOptionMenu beeps only
-- at .exitMenu (engine/menus/main_menu.asm). Silence where the original is
-- silent counts too, so this walks the mute cases as well. Invariants are in
-- tests/engine/menu_click_bug570.lua.
-- POKEPORT_DRIVER=tests/drivers/menu_click_bug570_test.lua POKEPORT_IDENTITY=bug570 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
-- No POKEPORT_SPEED: it scales the logic clock only while audio runs on its own
-- real-time accumulator in Game:update, so it desyncs the exact ordering of
-- press and click that this run exists to judge.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Bag = require("src.inventory.Bag")
local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local OptionsMenu = require("src.ui.OptionsMenu")
local PartyMenu = require("src.ui.PartyMenu")
local Pokemon = require("src.pokemon.Pokemon")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
-- pokered data/maps/objects/PalletTown.asm: OAK at (8,5), the girl at (3,8),
-- the fisher at (11,14), warps at (5,5), (13,5) and (12,11). (10,7) is clear
-- of all of them and off every mat; the menus are what is being judged, the
-- cell only has to be somewhere the start menu opens.
local MAP = "PALLET_TOWN"
local STAND = { x = 10, y = 7, facing = "down" }
local PARTY = { { "BULBASAUR", 12 }, { "PIDGEY", 9 } }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- what the ear cannot check -----------------------------------------
local opts = game.save.options or {}
local musicVol = opts.musicVol or 7
local sfxVol = opts.sfxVol or 7
if sfxVol == 0 then
U.log("FAIL SFX volume is 0. Every claim below is about a click that is or")
U.log(" is not there, and at 0 none of them are. Set SFX to 7 in OPTION")
U.log(" and run this again, or the whole check is worthless.")
end
if musicVol == 0 then
U.log("FAIL MUSIC volume is 0, so Pallet Town's theme is gone and there is")
U.log(" nothing to tell a dead audio device from a missing click.")
U.log(" Set MUSIC to 7 in OPTION.")
end
check(("SFX volume %d, MUSIC volume %d"):format(sfxVol, musicVol),
sfxVol > 0 and musicVol > 0)
-- an unresolved sfx key is silent in exactly the way the bug was
local sfx = (game.data.audio or {}).sfx or {}
for _, key in ipairs({ "Press_AB", "Start_Menu", "Swap" }) do
check("sfx " .. key .. " is in the cache", sfx[key] ~= nil)
end
-- every cue the run makes, forwarded so playback is untouched
local cues = {}
local realPlay = Sound.play
Sound.play = function(data, name)
cues[#cues + 1] = name
return realPlay(data, name)
end
local function since(mark)
local clicks, others = 0, {}
for i = mark + 1, #cues do
if cues[i] == "Press_AB" then clicks = clicks + 1
else others[#others + 1] = cues[i] end
end
return clicks, others
end
-- one press, then long enough for the click to be its own sound
local function press(btn, want, label)
local mark = #cues
U.tap(game, btn)
U.wait(28)
local clicks, others = since(mark)
local note = ""
if #others > 0 then note = " (also: " .. table.concat(others, ", ") .. ")" end
check(("%s: %s gave %d click%s, expected %d%s")
:format(label, btn:upper(), clicks, clicks == 1 and "" or "s",
want, note),
clicks == want)
return clicks
end
local function top() return game.stack:top() end
local function isA(class) return getmetatable(top()) == class end
-- ---- set the save up so every start-menu row is live --------------------
game.save.party = {}
for _, slot in ipairs(PARTY) do
table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2]))
end
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_POKEDEX = true -- StartMenu hides POKéDEX without it
game.save.pokedex = game.save.pokedex or { seen = {}, owned = {} }
for _, id in ipairs({ "BULBASAUR", "PIDGEY", "RATTATA" }) do
game.save.pokedex.seen[id] = true
game.save.pokedex.owned[id] = true
end
Bag.add(game.save, "POTION", 3)
Bag.add(game.save, "ANTIDOTE", 2)
game.save.startMenuIndex = 1
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(20)
local ow = game.overworld
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
-- a map edit blocked the cell: any free walkable neighbour will do
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
local cx, cy = STAND.x + d[1], STAND.y + d[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
cx, cy)
U.teleport(game, MAP, cx, cy, STAND.facing)
U.wait(20)
ow = game.overworld
break
end
end
end
check("standing on " .. MAP, ow ~= nil and ow.map.id == MAP)
-- the girl and the fisher both WALK: pin them so the map under the menus
-- looks the same on every run and in every screenshot
for _, n in ipairs((ow or {}).npcs or {}) do n.frozen = true end
-- ---- walk the menus -----------------------------------------------------
press("start", 0, "opening the start menu is silent (its own Start_Menu "
.. "cue is not the A/B click)")
check("the start menu is up", isA(Menu))
-- rows come and go with save state and the ui.start_menu.items hook, so
-- find each one by label rather than counting cursor steps
local function rowIndex(label)
for i, item in ipairs(top().items or {}) do
if item.label == label then return i end
end
return nil
end
local function openRow(label)
local menu = top()
local i = rowIndex(label)
if not i then
check("the start menu has a " .. label .. " row", false)
return false
end
menu.index = i
menu:clampScroll()
press("a", 1, "A on " .. label .. " clicks")
return true
end
if openRow(Strings("POKéDEX")) then
check("the POKéDEX list opened", isA(ListMenu))
U.shot(game, DIR .. "/bug570_1_pokedex.png")
press("b", 1, "backing out of POKéDEX clicks (#570)")
check("and the start menu came back", isA(Menu))
end
if openRow(Strings("POKéMON")) then
check("the party menu opened", getmetatable(top()) == PartyMenu)
press("b", 1, "backing out of POKéMON clicks (#570)")
check("and the start menu came back", isA(Menu))
end
if openRow(Strings("ITEM")) then
check("the bag opened", isA(ListMenu))
check("the bag has items to click on", #(top().items or {}) > 0)
press("a", 1, "the first A on an item in ITEM clicks (#570)")
check("the USE / TOSS submenu opened", isA(Menu))
U.shot(game, DIR .. "/bug570_2_bag_usetoss.png")
press("b", 1, "backing out of USE / TOSS clicks")
check("back on the item list", isA(ListMenu))
-- SELECT is inside DisplayListMenuID's watched mask but outside its
-- PAD_A | PAD_B sound test (home/list_menu.asm), so arming a swap is
-- silent and completing it makes the swap chirp, not the menu click
press("select", 0, "SELECT arms a swap without clicking")
press("select", 0, "and completing it chirps instead of clicking")
press("b", 1, "backing out of ITEM clicks (#570)")
check("and the start menu came back", isA(Menu))
end
if openRow(Strings("OPTION")) then
check("the options screen opened", getmetatable(top()) == OptionsMenu)
press("right", 0, "the Left/Right toggles are silent")
press("left", 0, "in both directions")
-- up from the first row wraps onto CANCEL, which sits one past the rows
press("up", 0, "moving the cursor is silent")
check("the cursor is on CANCEL",
top().index == #top().rows + 1)
U.shot(game, DIR .. "/bug570_3_option_cancel.png")
press("a", 1, "A on CANCEL in OPTION clicks (#570)")
check("and the start menu came back", isA(Menu))
end
if openRow(Strings("OPTION")) then
press("b", 1, "B out of OPTION clicks too (#570)")
check("and the start menu came back", isA(Menu))
end
press("start", 0, "START closes the start menu silently: draw_start_menu's "
.. "mask watches it, but it is not in the PAD_A | PAD_B sound branch")
check("back on the map", top() == game.overworld)
-- ---- over to you --------------------------------------------------------
U.log("The run above already pressed all of it; do it again by ear. Open START,")
U.log("go into POKéDEX, POKéMON, ITEM and OPTION and back out of each with B.")
U.log("Every one of those B presses makes the same short click that A on the")
U.log("row made going in, the first A on an item in ITEM makes it, and A on")
U.log("CANCEL in OPTION closes it with it. #570 was silence on all of those.")
U.log("The near miss is a click at the wrong moment: OPTION's Left/Right")
U.log("toggles and its A on a setting row stay silent, START opens and closes")
U.log("the start menu with its own longer cue and no click, and SELECT in the")
U.log("bag chirps a swap rather than clicking.")
U.log("Shots: " .. DIR .. "/bug570_*.png")
-- keeps printing the cue stream after the hand-off, so a click that lands a
-- beat late reads as a late line rather than as the right sound
local reported = #cues
while true do
if #cues > reported then
for i = reported + 1, #cues do
U.log("cue", cues[i], "frame", U.frame())
end
reported = #cues
end
coroutine.yield()
end
end
+211
View File
@@ -0,0 +1,211 @@
-- Look at the boxed menus whose rows moved down one tile (#564 start menu,
-- #572 the PC and the Pokédex entry window) and watch QUIT close the whole
-- Pokédex (#571). Row geometry comes from pokered draw_start_menu.asm
-- (hlcoord 12,2 inside a TextBoxBorder at 10,0) and players_pc.asm
-- (hlcoord 2,2 inside a 16x10 box); the exits come from pokedex.asm
-- HandlePokedexSideMenu / .exitPokedex. Screenshots land in $SHOT_DIR.
-- POKEPORT_DRIVER=tests/drivers/menu_windows_bug564_test.lua POKEPORT_IDENTITY=bug564 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Bag = require("src.inventory.Bag")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- The bedroom PC is hidden_event 0, 1, OpenRedsPC, SPRITE_FACING_UP
-- (pokered data/events/hidden_objects.asm), so the only approach is the
-- floor tile below it.
local MAP = "REDS_HOUSE_2F"
local STAND = { x = 0, y = 2, facing = "up" }
local DEX_SPECIES = "BULBASAUR" -- dex #1, so it is the list's first row
local BAG_ITEM = "POTION"
-- a save the start menu will draw all seven rows for: dex flag on,
-- something owned so the dex list has a real entry, a party for LINK,
-- and one bag item so ITEM opens onto a row that has a USE/TOSS box
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_POKEDEX = true
game.save.pokedex = game.save.pokedex or { seen = {}, owned = {} }
game.save.pokedex.seen[DEX_SPECIES] = true
game.save.pokedex.owned[DEX_SPECIES] = true
game.save.party = {
Pokemon.new(game.data, DEX_SPECIES, 12),
Pokemon.new(game.data, "PIKACHU", 10),
}
Bag.add(game.save, BAG_ITEM, 3)
check("dex entry for " .. DEX_SPECIES .. " exists in the loaded data",
game.data.pokemon[DEX_SPECIES] ~= nil)
check(BAG_ITEM .. " resolves as an item",
game.data.items[BAG_ITEM] ~= nil)
-- pcTiles is what OverworldController checks before it opens the PC; an
-- empty list here and a mis-aimed player look identical from outside
local extras = game.data.field and game.data.field.hiddenExtras
local pcTile = extras and (extras.pcTiles[MAP] or {})[1]
check("the bedroom PC tile is in the field data", pcTile ~= nil)
if pcTile then
U.log("PC tile at", pcTile.x, pcTile.y, "facing", pcTile.facing)
-- a map or field-data edit moves the tile: stand under it wherever it is
STAND = { x = pcTile.x, y = pcTile.y + 1, facing = "up" }
end
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local ow = game.overworld
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
-- fall back to any free walkable neighbour that still looks at the tile
local sides = {
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
for _, s in ipairs(sides) do
local cx, cy = pcTile.x + s[1], pcTile.y + s[2]
if ow.map:isWalkableCell(cx, cy) then
U.log("standing on", cx, cy, "facing", s[3], "instead")
STAND = { x = cx, y = cy, facing = s[3] }
U.teleport(game, MAP, cx, cy, s[3])
U.wait(10)
break
end
end
end
check("player is standing at the PC",
game.overworld ~= nil
and game.overworld.player.cellX == STAND.x
and game.overworld.player.cellY == STAND.y)
-- walk a Menu's cursor onto a row by label, with real Down presses
local function moveTo(menu, label)
for _ = 1, #menu.items + 1 do
if menu.items[menu.index] and menu.items[menu.index].label == label then
return true
end
U.tap(game, "down")
U.wait(2)
end
return false
end
-- --------------------------------------------------------------- #564
U.tap(game, "start")
U.wait(20)
local start = game.stack:top()
check("START opened the start menu", start ~= nil and start.items ~= nil)
if start and start.items then
local labels = {}
for i, it in ipairs(start.items) do labels[i] = it.label end
U.log("start menu rows:", table.concat(labels, " "))
end
check("start menu screenshot",
U.shot(game, SHOT_DIR .. "/bug564_start_menu.png"))
-- --------------------------------------------------------------- #571
check("POKéDEX is on the start menu", moveTo(start, "POKéDEX"))
U.tap(game, "a")
U.wait(20)
local dex = game.stack:top()
check("the dex list opened", dex ~= nil and dex.screenId == "PokedexMenu")
U.tap(game, "a")
U.wait(20)
local side = game.stack:top()
local isSide = side ~= nil and side ~= dex and side.items ~= nil
check("A on " .. DEX_SPECIES .. " opened the DATA/CRY/AREA/QUIT window",
isSide)
check("Pokédex entry window screenshot",
U.shot(game, SHOT_DIR .. "/bug572_pokedex_entry_menu.png"))
if isSide then
check("QUIT is on the entry window", moveTo(side, "QUIT"))
U.tap(game, "a")
U.wait(30)
local top = game.stack:top()
-- the machine-checkable half of #571: the stack has to unwind past the
-- dex list, not just pop the side menu
check("QUIT left the Pokédex entirely", top ~= dex and top ~= side)
check("QUIT landed on the start menu",
top ~= nil and top.screenId == "StartMenu")
if top and top.items and top.items[top.index] then
U.log("start menu cursor is on:", top.items[top.index].label)
check("the cursor is still on POKéDEX",
top.items[top.index].label == "POKéDEX")
end
check("start menu after QUIT screenshot",
U.shot(game, SHOT_DIR .. "/bug571_after_quit.png"))
end
-- --------------------------------------------------------------- #572
-- the bag's USE/TOSS box, reached from the same start menu
local menu = game.stack:top()
if menu and menu.items and moveTo(menu, "ITEM") then
U.tap(game, "a")
U.wait(20)
U.tap(game, "a") -- A on the first bag row opens USE/TOSS
U.wait(20)
local useToss = game.stack:top()
local ok = useToss and useToss.items and #useToss.items == 2
and useToss.items[1].label == "USE"
check("the bag's USE/TOSS box opened", ok and true or false)
check("USE/TOSS screenshot",
U.shot(game, SHOT_DIR .. "/bug572_bag_use_toss.png"))
if ok then
-- pokered data/text_boxes.asm USE_TOSS_MENU_TEMPLATE is (13,10) to
-- (19,14) with its text at 15,11: two rows exactly fill 11 and 13,
-- with no blank row under the top border. Mirror Menu:draw's own
-- bottom anchor rather than re-deriving one here -- an assertion that
-- carries its own copy of the formula passes on whatever it assumes
-- instead of on what the menu actually draws (#564, #572).
local n = #useToss.items
local bottom = useToss.ty + useToss.th - 2
local firstRow = bottom - (n - 1) * useToss.rowStep
local lastRow = bottom
check("TOSS is inside the box, not on its bottom border",
lastRow <= useToss.ty + useToss.th - 2)
check("USE and TOSS land on the pokered rows (11 and 13)",
firstRow == 11 and lastRow == 13)
U.log("USE/TOSS box ty=" .. useToss.ty .. " th=" .. useToss.th
.. ", rows land on " .. firstRow
.. " and " .. lastRow
.. ", bottom interior row is " .. bottom)
end
U.tap(game, "b")
U.wait(10)
U.tap(game, "b")
U.wait(20)
end
-- back out to the overworld and open the bedroom PC
for _ = 1, 4 do
if game.stack:top() == game.overworld then break end
U.tap(game, "b")
U.wait(15)
end
check("back in the overworld", game.stack:top() == game.overworld)
U.tap(game, "a")
U.wait(25)
local pc = game.stack:top()
local isPC = pc ~= nil and pc.screenId == "PlayerPC"
check("the bedroom PC opened", isPC)
check("player's PC screenshot",
U.shot(game, SHOT_DIR .. "/bug572_player_pc.png"))
U.log("The player's PC is open on screen; B closes it and START reopens")
U.log("the start menu. In all of these the last choice should sit on the")
U.log("row just above the bottom border and the blank row should be under")
U.log("the top border, with the ▶ level with its label -- the near miss is")
U.log("text that clears the top edge but leaves a gap at the bottom, or a")
U.log("cursor that drifts a row off its own label. The text boxes the")
U.log("Pokémon and item lists draw are the control: they never moved.")
U.log("the bag's USE/TOSS box is the tight one: pokered draws it a row")
U.log("shorter than the rest, so USE and TOSS fill 11 and 13 with no blank")
U.log("row at all. If TOSS is sitting on the border there, that is the bug.")
U.log("Screenshots are under " .. SHOT_DIR)
while true do
coroutine.yield()
end
end
+112
View File
@@ -0,0 +1,112 @@
-- Driver: regression coverage for #601 "Wrong dialogue when interacting
-- with Prof. Oak's last ball".
--
-- After the player picks a starter and the rival takes his, the leftover
-- ball on the lab table must show "That's PROF.OAK's last Pokémon!" --
-- pret/pokered scripts/OaksLab.asm OaksLabSelectedPokeBallScript jumps
-- every ball handler to OaksLabLastMonScript once EVENT_GOT_STARTER is
-- set (Oak turns to face the player first). The buggy port fell through
-- to _OaksLabThoseArePokeBallsText ("Those are POKé BALLs...") instead.
--
-- Scenario A (the #601 regression): with a starter already picked, talk
-- to the leftover ball -> Oak faces down, box says "last Pokémon!",
-- and no starter offer/dex appears. Fails before the fix (the box
-- says "Those are POKé BALLs").
-- Scenario B (guard): with NO starter and not escorted in, the ball still
-- says "Those are POKé BALLs". Passes before and after the fix.
--
-- Setup: flags are set directly (pick flow never runs), so all three
-- balls stay visible; the player stands left of the Charmander ball
-- (cell 6,3), the leftover slot for the Squirtle pick (rival took the
-- Bulbasaur ball). The lab battle flag is set so the rival is gone and
-- cannot intercept the talk. TextBox.new is hooked to capture the raw
-- box text.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local TextBox = require("src.render.TextBox")
local origNew = TextBox.new
local lastText
TextBox.new = function(g, text, ...)
lastText = text
return origNew(g, text, ...)
end
local function restore()
TextBox.new = origNew
end
local function setFlags(postPick)
local flags = game.save.flags or {}
game.save.flags = flags
flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
if postPick then
flags.EVENT_GOT_STARTER = true
flags.EVENT_CHOSE_SQUIRTLE = true
else
flags.EVENT_GOT_STARTER = nil
end
-- rival already fought + gone, so he cannot intercept the talk
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
end
-- Talk to the ball at cell 6,3 (Charmander slot): stand one cell left
-- facing right and press A. Returns once a TextBox has been built.
local function talkToBall()
lastText = nil
U.teleport(game, "OAKS_LAB", 5, 3, "right")
U.wait(6)
for _ = 1, 8 do
U.tap(game, "a")
for _ = 1, 30 do
if lastText then return true end
U.wait(1)
end
end
return lastText ~= nil
end
-- ---- Scenario A: leftover ball after the pick
setFlags(true)
local aBoxOpened = talkToBall()
U.wait(30) -- let the typewriter reveal the line
U.shot(game, DIR .. "/a_last_ball.png")
local aText = lastText or "<none>"
local aPass = aBoxOpened
and aText:find("last Pokémon!", 1, true) ~= nil
and aText:find("Those are", 1, true) == nil
U.log("SCENARIO A box:", aText)
U.log("SCENARIO A", aPass and "PASS" or "FAIL")
-- close the box
for _ = 1, 10 do
if game.stack:top() == game.overworld then break end
U.tap(game, "a")
U.wait(2)
end
-- ---- Scenario B: pre-escort ball text unchanged
setFlags(false)
local bBoxOpened = talkToBall()
U.wait(30)
U.shot(game, DIR .. "/b_pre_escort.png")
local bText = lastText or "<none>"
local bPass = bBoxOpened
and bText:find("ThoseArePokeBalls", 1, true) ~= nil
or bText:find("Those are", 1, true) ~= nil
U.log("SCENARIO B box:", bText)
U.log("SCENARIO B", bPass and "PASS" or "FAIL")
-- restore hooks before any assert so a failure can't leave them installed
restore()
U.log("RESULT bug601", (aPass and bPass) and "PASS" or "FAIL")
assert(aPass,
"Leftover ball after the pick must say 'That's PROF.OAK's last "
.. "Pokémon!' (no 'Those are POKé BALLs'); got: " .. aText)
assert(bPass,
"Pre-escort balls must keep the 'Those are POKé BALLs' line; got: "
.. bText)
end
@@ -0,0 +1,262 @@
-- Manual check of Yellow's two extra pic rips: PROF.OAK's own back pic in
-- the Pallet catch scene (#557, LoadPlayerBackPic's BATTLE_TYPE_PIKACHU
-- branch, pokeyellow engine/battle/core.asm:6384-6391) and the framed
-- portrait TalkToPikachu raises (#561, each PikaPicAnimScript's own base
-- frame, data/pikachu/pikachu_pic_animation.asm). Both are gated on files
-- the importer writes, so the asset check comes before any staging. No
-- POKEPORT_IDENTITY: a sandbox save dir carries no Yellow cache at all.
-- POKEPORT_DRIVER=tests/drivers/oak_pikapic_bug557_bug561_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local GameVersion = require("src.core.GameVersion")
local BattleState = require("src.battle.BattleState")
local Sprites = require("src.pokemon.Sprites")
local Pokemon = require("src.pokemon.Pokemon")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local PROF_BACK = "assets/generated/battle/profoakb.png"
local OLDMAN_BACK = "assets/generated/battle/oldmanb.png"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function idle()
while true do coroutine.yield() end
end
if not check("running the Yellow cache (POKEPORT_VERSION=yellow)",
GameVersion.isYellow()) then
U.log("Neither pic exists in Red or Blue. Re-run with")
U.log("POKEPORT_VERSION=yellow.")
idle()
end
-- ---- the two rips, before anything is staged ----
-- The importer writes these only when the manifest carries the symbol, and
-- both call sites fall back silently when the file is absent, so a missing
-- file looks on screen exactly like the bug being unfixed.
local oakPicOk = love.filesystem.getInfo(PROF_BACK) ~= nil
check("Oak's back pic is in the cache (" .. PROF_BACK .. ")", oakPicOk)
if not oakPicOk then
U.log("The importer skipped it: tools/rom_manifest_yellow.json has no")
U.log("ProfOakPicBack entry, so RomExtractor's gate never fires and")
U.log("Sprites.playerPath falls back to " .. OLDMAN_BACK .. ".")
U.log("Re-importing will not help until the symbol is in the manifest.")
end
local pikapicFound = {}
for script = 1, 28 do
local path = "assets/generated/pikachu/pikapic_" .. script .. ".png"
if love.filesystem.getInfo(path) then
pikapicFound[#pikapicFound + 1] = script
end
end
local pikapicOk = #pikapicFound > 0
check("the pikapic base frames are in the cache (assets/generated/pikachu/)",
pikapicOk)
if pikapicOk then
U.log("found base frames for scripts:", table.concat(pikapicFound, " "))
else
U.log("The importer skipped all 28: the Pic_e4000..Pic_f0cf4 labels are")
U.log("absent from tools/rom_manifest_yellow.json, so PikachuFollower")
U.log("keeps standing the battle front pic in the frame, which is #561")
U.log("unchanged. Not staging the talk: it cannot look right yet.")
end
U.log("tests/engine/yellow_oak_back_pikapic.lua asserts both symbol lists.")
-- ---- #557: the Pallet catch scene ----
-- Same two calls data/scripts/story2.lua:255 makes once Oak has said
-- "That was close!" Standing in Pallet Town first so the battle draws
-- over a real map, as it does in play.
U.teleport(game, "PALLET_TOWN", 10, 8, "up")
U.wait(10)
game.save.player.name = "bryan"
local backPath = Sprites.playerPath(game.data, "back",
{ kind = "battle", demo = true, oakDemo = true })
U.log("the demo resolves its back pic to:", tostring(backPath))
check("it is Oak's pic, not the old man's", backPath == PROF_BACK)
local battle = BattleState.newWild(game, "PIKACHU", 5)
battle:makeOldManDemo("PROF.OAK")
check("the thrower is named PROF.OAK", battle.demoName == "PROF.OAK")
check("the battle asks for the BATTLE_TYPE_PIKACHU back pic",
battle.oakDemo == true)
-- record what the scripted throw actually prints, so the name half can be
-- judged without reading glyph codes off the canvas
local said = {}
local realSay = battle.say
battle.say = function(self, text)
said[#said + 1] = tostring(text)
return realSay(self, text)
end
game.stack:push(battle)
U.wait(10)
-- clear the "Wild PIKACHU appeared!" box; the scripted cursor script only
-- starts once the intro chrome is gone
for _ = 1, 90 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(4)
end
if not check("the demo battle reached its scripted menu",
battle.phase == "menu") then
U.log("phase is", tostring(battle.phase), "- the intro never cleared.")
idle()
end
U.wait(20)
if U.shot(game, SHOT_DIR .. "/bug557_back_pic.png") then
U.log("captured", SHOT_DIR .. "/bug557_back_pic.png")
end
-- The other half of the issue title, "referred to as Pikachu".
-- DisplayBattleMenu's simulated branch puts BATTLE_MENU_TEMPLATE on screen
-- and nothing else (data/text_boxes.asm:31 -- FIGHT/PKMN/ITEM/RUN from
-- column 8), so no name is printed at all: there is no player mon in this
-- battle to name. The classic layout ports that branch; WideBattle's menu
-- has no demo case and prints "What will <battle.player.name> do?" over the
-- hidden placeholder battler, which is a level 5 PIKACHU.
local options = game.save.options or {}
U.log("battle layout option:", tostring(options.battleLayout or "classic"))
local wide = battle:isWideBattleLayout()
check("the layout on screen leaves the scripted menu unnamed", not wide)
if wide then
U.log("The wide layout is on, so the menu reads \"What will PIKACHU do?\"")
U.log("beside Oak's throw. Switch OPTIONS to the classic battle layout")
U.log("and re-run to see the box the original draws.")
end
-- the rest runs itself: 130 frames of cursor, the forced ITEM list, then
-- the throw. Never tap through it -- the timing is the port of
-- DisplayBattleMenu's simulated keystrokes.
local function thrownLine()
for _, text in ipairs(said) do
if text:find("POK", 1, true) and text:find("PROF.OAK", 1, true) then
return text
end
end
return nil
end
for _ = 1, 600 do
if thrownLine() then break end
U.wait(1)
end
local line = thrownLine()
check("the throw is announced under PROF.OAK's name", line ~= nil)
if line then U.log("the box reads:", (line:gsub("\n", " / "))) end
U.wait(45)
if U.shot(game, SHOT_DIR .. "/bug557_throw.png") then
U.log("captured", SHOT_DIR .. "/bug557_throw.png")
end
if oakPicOk then
U.log("Both #557 shots are taken. The back pic in the lower left is the")
U.log("man throwing the ball: Oak in his lab coat, and the throw line")
U.log("names PROF.OAK. The near miss is a back pic that reads as a")
U.log("person and looks plausible while still being oldmanb.png --")
U.log("compare against the catch tutorial north of Viridian, which")
U.log("SHOULD be the old man and proves the demo path works at all.")
else
U.log("The shots are evidence of the FAIL, not of a fix: the back pic in")
U.log("the lower left is the bald OLD MAN from the Route 5 tutorial,")
U.log("exactly as #557 reported him. The throw line above it does name")
U.log("PROF.OAK, so the thrower half is right and only the pic is stuck.")
end
-- ---- #561: the framed portrait ----
if not pikapicOk then
U.log("Skipping the Pikachu portrait: see the FAIL above.")
idle()
end
for _ = 1, 300 do
if game.stack:top() ~= battle then break end
U.wait(1)
end
U.wait(20)
-- ShouldPikachuSpawn (pokeyellow engine/pikachu/pikachu_follow.asm): the
-- lab gift happened and a healthy Pikachu leads the party. Pallet's
-- objects sit at (10,4), (3,8) and (11,14), so (10,8) and the road cells
-- around it are clear.
game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) }
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(12)
local ow = game.overworld
local function follower()
for _, n in ipairs(ow.npcs or {}) do
if n.pikachuFollower then return n end
end
return nil
end
local npc = follower()
if not check("the follower spawned", npc ~= nil) then idle() end
-- one step then a turn back, so the follower is on the cell just vacated
local DIRS = { { "down", 0, 1 }, { "up", 0, -1 },
{ "left", -1, 0 }, { "right", 1, 0 } }
local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" }
local stepDir
for _, d in ipairs(DIRS) do
local cx, cy = ow.player.cellX + d[2], ow.player.cellY + d[3]
if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy)
and not ow:npcAtCell(cx, cy) then
stepDir = d[1]
break
end
end
if not check("a walkable neighbour to step into exists", stepDir ~= nil) then
idle()
end
U.hold(game, stepDir, 24)
U.wait(6)
U.tap(game, OPPOSITE[stepDir])
U.wait(6)
local function facingFollower()
local fx, fy = ow.player:facingCell()
return ow:npcAtCell(fx, fy) == npc
end
if not facingFollower() then
for _, d in ipairs(DIRS) do
if npc.cellX == ow.player.cellX + d[2]
and npc.cellY == ow.player.cellY + d[3] then
U.tap(game, d[1])
U.wait(6)
break
end
end
end
check("player is facing the follower", facingFollower())
U.tap(game, "a")
U.wait(8)
local emote = ow.emote
if not check("the A press raised a portrait", emote ~= nil and emote.pikaPic) then
U.log("Nothing answered the press, which is #407 territory, not #561.")
idle()
end
U.log("the frame is drawing:", tostring(emote.pikaPic))
check("it is this script's own base frame, not the battle front pic",
emote.pikaPic:find("pikachu/pikapic_", 1, true) ~= nil)
if U.shot(game, SHOT_DIR .. "/bug561_portrait.png") then
U.log("captured", SHOT_DIR .. "/bug561_portrait.png")
end
U.log("The portrait box is up over the map now. Inside the 5x5 frame is a")
U.log("posed Pikachu drawn for that emotion, not the battle front pic")
U.log("standing to attention -- that stand-in is the screenshot on #561.")
U.log("Face it and press A again for another emotion; the pose should")
U.log("change with it. A frame that never changes between emotions is the")
U.log("near miss: the path resolved but every script picked one base.")
idle()
end
+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
+241 -26
View File
@@ -1,40 +1,255 @@
-- Driver: a quick directional tap should turn the player in place, not
-- also take a step (#415). Player:tryMove (src/world/Player.lua) only
-- steps once turnTimer has counted down; TURN_FRAMES/turnFrames set how
-- many fixed steps a facing change blocks movement for before a held
-- direction is allowed to commit to a step. This is an input-timing feel
-- bug: no assertion here can tell a quick tap from a held one the way a
-- human thumb can, so this hands the pad over rather than scripting taps
-- at a fixed, unrealistic frame count.
--
-- 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
-- REDS_HOUSE_2F: the player's own bedroom, the default new-game spawn
-- (src/core/SaveData.lua bedroom fallback). Small enough that an
-- accidental step is obvious -- a stray step toward the stairs at
-- (7,1)/(7,2) or into the desk/bed furniture is immediately visible.
U.teleport(game, "REDS_HOUSE_2F", 5, 6, "down")
U.wait(10)
local ow = game.stack:top()
check("Red's bedroom is loaded", ow.map.id == "REDS_HOUSE_2F")
check("player starts facing down", ow.player.facing == "down")
-- 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 }
U.log("Standing in the middle of the bedroom, facing down.")
U.log("Tap Up once, quickly, then tap Right once, quickly.")
U.log("Right: each tap only turns you to face that way -- your feet")
U.log("stay on the same tile both times. Wrong (#415): a quick tap")
U.log("still slides you one tile in the new direction, same as holding.")
U.log("Now hold Up for a beat: turning into a real step, then a walk,")
U.log("should feel identical to before -- no extra pause was added there.")
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()
@@ -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
+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")
+31
View File
@@ -297,4 +297,35 @@ do
eq(rows[1].name, "bare", "a nameless row falls back to its id")
end
-- ------- manifest strings are scrubbed to valid UTF-8 (MODS panel crash:
-- LÖVE's printf raises "Invalid UTF-8" on a mangled name/description, so
-- validate must drop bad bytes before any panel draws them)
do
local m = mf({ id = "utf", entry = "m.lua",
-- BOM-prefixed name (a real manifest shipped this way), a Latin-1 e-acute
-- (\233, invalid as UTF-8) in the description, and a lone continuation
-- byte in the version
name = "\239\187\191Run Mode",
version = "1.0\128.0",
description = "caf\233 latt\233",
category = "UI\255" })
eq(m.name, "Run Mode", "a leading BOM is stripped from the name")
eq(m.version, "1.0.0", "invalid bytes are dropped from the version")
eq(m.description, "caf latt", "Latin-1 bytes are dropped, not replaced")
eq(m.raw.category, "UI", "raw.category is scrubbed in place for the badge")
local ok2 = mf({ id = "utf2", name = "Vers\195\163oVermelha", version = "1.0.0",
entry = "m.lua", description = "Pok\195\169mon \240\159\148\165" })
eq(ok2.name, "Vers\195\163oVermelha", "valid two-byte sequences survive")
eq(ok2.description, "Pok\195\169mon \240\159\148\165",
"valid three- and four-byte sequences survive")
-- surrogate half (ED A0 80) and overlong slash (C0 AF) are invalid even
-- though their lead bytes look plausible
local bad = mf({ id = "utf3", name = "a\237\160\128b\192\175c",
version = "1.0.0", entry = "m.lua" })
eq(bad.name, "abc", "surrogates and overlongs are dropped")
end
T.finish("launcher_mods")
+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")
+129
View File
@@ -0,0 +1,129 @@
-- Regression coverage for #601 "Wrong dialogue when interacting with Prof.
-- Oak's last ball" (T2, ROM-free).
--
-- pret/pokered scripts/OaksLab.asm OaksLabSelectedPokeBallScript: once
-- EVENT_GOT_STARTER is set, EVERY ball's text handler jumps to
-- OaksLabLastMonScript -- Oak turns to face the player and reads
-- "_OaksLabLastMonText" ("That's PROF.OAK's last #MON!") instead of
-- re-offering the starter. The buggy port fell through to
-- _OaksLabThoseArePokeBallsText ("Those are POKé BALLs...") on every ball
-- once a starter had been picked. The fix also spells the ROM's "#MON"
-- ligature out as "Pokémon".
--
-- The three ball scripts share one table (starterBall), so this suite
-- drives that table through a mini ScriptRunner-compatible executor:
-- flag checks, jumps, "end" halts and text rows are executed, UI-heavy
-- commands (push_screen, ask, give_pokemon, npc moves) are no-ops with
-- ask recording the offer text. It then asserts the whole flow:
-- * GOT_STARTER + talk -> Oak faces down + "last Pokémon!" line, ends
-- * no GOT_STARTER, not escorted in -> "Those are POKé BALLs"
-- * no GOT_STARTER, escorted in -> the dex/ask offer (unchanged path)
-- plus MapScripts.validateContribution stays clean (the pre-fix table
-- carried nine out-of-range "jump 21" findings -- its run-time "end").
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local MapScripts = require("src.script.MapScripts")
local contribution = dofile("data/scripts/oaks_lab.lua")
local problems = MapScripts.validateContribution(contribution)
T.eq(#problems, 0, "oaks_lab contribution validates cleanly")
for _, p in ipairs(problems) do
T.check(false, "unexpected finding: " .. p)
end
local BALL = "TEXT_OAKSLAB_CHARMANDER_POKE_BALL"
-- ---- mini executor over the talk rows (ScriptRunner semantics: a jump
-- command returns the next row index or "end" to halt)
local function run(script, flags, answer)
local pc, texts, offers = 1, {}, {}
local lastCheck = nil
while pc <= #script do
local row = script[pc]
local verb = row[1]
if verb == "check_flag" then
lastCheck = flags[row[2]] == true
elseif verb == "jump_if_true" then
if lastCheck then
if row[2] == "end" then break end
pc = row[2] goto next
end
elseif verb == "jump_if_false" then
if not lastCheck then
if row[2] == "end" then break end
pc = row[2] goto next
end
elseif verb == "jump" then
if row[2] == "end" then break end
pc = row[2]
goto next
elseif verb == "show_text" then
texts[#texts + 1] = row[2]
elseif verb == "ask" then
offers[#offers + 1] = row[2]
if answer == false then
pc = pc + 1 -- decline: the next row's jump_if_false decides
goto next
end
end
-- push_screen / give_pokemon / set_flag / hide_object / move_npc_to /
-- face_object: no-op here (set_flag is exercised via the fixture
-- flags table instead of being run)
pc = pc + 1
::next::
end
return texts, offers
end
local function concat(list)
return table.concat(list, "\n")
end
-- ---- leftover ball after the pick: Oak faces down + the last-mon line
local got = { EVENT_GOT_STARTER = true, EVENT_FOLLOWED_OAK_INTO_LAB = true }
local texts, offers = run(contribution.talk[BALL], got, true)
T.eq(#offers, 0, "no starter offer after the pick")
local box = concat(texts)
T.check(box:find("last Pokémon!", 1, true) ~= nil,
"leftover ball says the last-mon line (got: " .. box .. ")")
T.check(box:find("Those are", 1, true) == nil,
"leftover ball no longer says 'Those are POKé BALLs'")
T.check(box:find("#MON", 1, true) == nil,
"the ROM #MON ligature is spelled out as Pokémon")
-- the pokered beat also turns Oak to face the player
T.check(contribution.talk[BALL][20][1] == "face_object"
and contribution.talk[BALL][20][2] == 5
and contribution.talk[BALL][20][3] == "down",
"row 20 faces Oak down before the line (OaksLabLastMonScript)")
-- ---- pre-escort: still the vanilla "Those are POKé BALLs" line
local pre = { EVENT_GOT_STARTER = false, EVENT_FOLLOWED_OAK_INTO_LAB = false }
local t2, o2 = run(contribution.talk[BALL], pre, true)
T.eq(#o2, 0, "no offer before Oak escorts the player in")
T.check(concat(t2):find("ThoseArePokeBalls", 1, true) ~= nil,
"pre-escort balls keep the 'Those are POKé BALLs' line")
-- ---- escorted in but no pick yet: the dex + "You want X?" offer
local mid = { EVENT_GOT_STARTER = false, EVENT_FOLLOWED_OAK_INTO_LAB = true }
local t3, o3 = run(contribution.talk[BALL], mid, true)
T.eq(#o3, 1, "the starter offer still runs before the pick")
T.check(concat(t3):find("last Pokémon!", 1, true) == nil,
"no last-mon line before the pick")
-- ---- all three balls share the same table shape (last-mon beat present)
for _, key in ipairs({
"TEXT_OAKSLAB_CHARMANDER_POKE_BALL",
"TEXT_OAKSLAB_SQUIRTLE_POKE_BALL",
"TEXT_OAKSLAB_BULBASAUR_POKE_BALL",
}) do
local script = contribution.talk[key]
T.check(script and script[21] and script[21][2] and
script[21][2]:find("Pokémon", 1, true) ~= nil,
key .. " carries the last-mon line")
end
T.finish("oaks_lab_last_ball_bug601")
+163
View File
@@ -0,0 +1,163 @@
-- Prof. Oak's PC session (#576): engine/menus/oaks_pc.asm OpenOaksPC --
-- the access text, "Want to get your #DEX rated?" with a YES/NO, the dex
-- rating (completion line + tier text), the rating jingle only once the
-- rating text has printed (DisplayDexRating -> PlayPokedexRatingSfx), and
-- the "Closed link to PROF.OAK's PC." tail before control returns. The
-- old flow played the jingle the moment the entry was picked and skipped
-- both the intro and the closing link.
-- ROM-free: uses the fixture dataset so CI (no data/generated/) stays green.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
-- the ROM-extracted strings the fixture text table does not carry; labels
-- and wording match pokered text/pokedex_ratings.asm + oaks_pc.asm
Data.text._AccessedOaksPCText =
"Accessed PROF.\nOAK's PC.\fAccessed #DEX\nRating System."
Data.text._GetDexRatedText = "Want to get your\n#DEX rated?"
Data.text._ClosedOaksPCText = "Closed link to\nPROF.OAK's PC."
Data.text._DexCompletionText =
"#DEX comp-\nletion is:\f{NUM:hDexRatingNumMonsSeen} #MON seen\n" ..
"{NUM:hDexRatingNumMonsOwned} #MON owned\fPROF.OAK's\nRating:"
Data.text._DexRatingText_Own50To59 =
"You finally got at\nleast 50 species!"
local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed = {}
local plays = {}
local stackStub = {
push = function(_, item)
pushed[#pushed + 1] = item
end,
}
local textBoxStub = {
new = function(_, text, onDone, opts)
return { kind = "text", text = text, onDone = onDone, opts = opts }
end,
}
local menuStub = {
new = function(_, items, opts)
return { kind = "menu", items = items, opts = opts or {} }
end,
}
-- Sound / Menu are required lazily at the call sites; stub via package.loaded
local realSound = package.loaded["src.core.Sound"]
package.loaded["src.core.Sound"] = {
play = function(_, name)
plays[#plays + 1] = name
end,
playCry = function() end,
}
local realMenu = package.loaded["src.ui.Menu"]
package.loaded["src.ui.Menu"] = menuStub
local fakeGame = {
data = Data,
save = SaveData.newGame(),
stack = stackStub,
}
for _, name in ipairs({ "openOaksPC", "dexRating" }) do
T.check(setUpvalue(OW[name], "TextBox", textBoxStub),
("TextBox upvalue on %s"):format(name))
T.check(setUpvalue(OW[name], "Game", fakeGame),
("Game upvalue on %s"):format(name))
end
T.check(setUpvalue(OW.openPC, "Game", fakeGame), "Game upvalue on openPC")
local fakeSelf = setmetatable({}, { __index = OW })
local function lastPush()
return pushed[#pushed]
end
local function reset()
fakeGame.save = SaveData.newGame()
fakeGame.save.flags.EVENT_GOT_POKEDEX = true
local seen, owned = {}, {}
for i = 1, 55 do seen[i] = true; owned[i] = true end
fakeGame.save.pokedex = { seen = seen, owned = owned }
pushed = {}
plays = {}
end
local function runChain()
-- A through every box that is up; the choice box answers YES
local guard = 0
while lastPush() and lastPush().kind == "text" and guard < 10 do
guard = guard + 1
local box = lastPush()
if box.opts and box.opts.choice then
box.opts.choice(true)
elseif box.onDone then
box.onDone()
else
break
end
end
end
-- === full session from the launcher menu: intro, YES, rating, jingle, close
reset()
local done = false
fakeSelf:openPC(function() done = true end)
local menu = lastPush()
T.eq(menu.kind, "menu", "openPC pushes the PC menu")
local oak
for _, item in ipairs(menu.items) do
if item.label == "PROF.OAK's PC" then oak = item end
end
T.check(oak ~= nil, "PROF.OAK's PC is offered once the Pokédex is had")
plays = {} -- drop the menu's Turn_On_PC; the session's jingle is what counts
oak.onSelect()
T.eq(pushed[2].kind, "text", "selection opens the access text")
T.check(tostring(pushed[2].text):find("Accessed", 1, true) ~= nil,
"first session box is the access text")
runChain()
T.check(done, "session completes")
T.check(pushed[3].opts ~= nil and pushed[3].opts.choice ~= nil,
"the rated question carries the YES/NO choice")
T.check(tostring(pushed[3].text):find("rated", 1, true) ~= nil,
"second session box asks for the rating")
local ratingBox = pushed[4]
T.check(ratingBox.opts and ratingBox.opts.auto ~= nil
and ratingBox.opts.auto.wait ~= nil,
"rating box sounds the jingle then waits for a button")
T.check(tostring(ratingBox.text):find("55", 1, true) ~= nil,
"completion line carries the seen/owned counts")
T.check(tostring(ratingBox.text):find("least 50 species", 1, true) ~= nil,
"rating box carries the Own50To59 tier text")
T.eq(#plays, 0, "no jingle while the evaluation is printing")
ratingBox.opts.auto.sound()
T.eq(#plays, 1, "jingle fires once the rating text is printed")
T.eq(plays[1], "Pokedex_Rating", "jingle is the Pokedex_Rating fanfare")
T.check(tostring(pushed[5].text):find("Closed link", 1, true) ~= nil,
"the closing link prints at the end of the session")
-- === declining the rating skips the evaluation but still closes the link
reset()
done = false
fakeSelf:openOaksPC(function() done = true end)
T.eq(pushed[1].kind, "text", "openOaksPC opens with the access text")
pushed[1].onDone()
T.check(pushed[2].opts and pushed[2].opts.choice ~= nil,
"rated question is a YES/NO")
pushed[2].opts.choice(false)
T.check(tostring(lastPush().text):find("Closed link", 1, true) ~= nil,
"NO skips the rating and closes the PC")
T.eq(#plays, 0, "declining plays no jingle")
package.loaded["src.ui.Menu"] = realMenu
if realSound ~= nil then package.loaded["src.core.Sound"] = realSound end
T.finish("oaks_pc_flow")
+94
View File
@@ -0,0 +1,94 @@
-- QUIT on a Pokédex entry closes the whole Pokédex (#571).
-- HandlePokedexSideMenu hands ShowPokedexMenu b=1 for QUIT and b=2 for B
-- (engine/menus/pokedex.asm); only b=2 loops back to .doPokemonListMenu,
-- b=1 falls through to .exitPokedex, which drops the dex and returns to
-- whoever opened it with wBattleAndStartSavedMenuItem intact. The port
-- popped the side menu and left the list up, so QUIT looked like B.
-- ROM-free: real StateStack and real screens over tests/fixture_data.
-- luajit tests/engine/pokedex_quit_bug571.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local check, eq = T.check, T.eq
local Data = T.fixtures.load()
local SaveData = require("src.core.SaveData")
local Screens = require("src.ui.Screens")
local StateStack = require("src.core.StateStack")
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 } }
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
local function press(game, btn)
game.input.queue = { [btn] = true }
game.stack:update(1 / 60)
game.input.queue = {}
end
-- what the stack is carrying, named the way Screens tags its screens
local function stackIds(game)
local ids = {}
for i, state in ipairs(game.stack.states) do
ids[i] = tostring(state.screenId or "sideMenu")
end
return table.concat(ids, " > ")
end
local function openSideMenu(game)
local start = Screens.push(game, "StartMenu")
eq(start.items[start.index].label, "POKéDEX",
"the start menu opens with the cursor on POKéDEX")
press(game, "a")
local list = game.stack:top()
eq(list.screenId, "PokedexMenu", "POKéDEX opens the dex list")
eq(list.items[list.index].value, "FIXMON_A",
"the cursor is on a seen species, so A opens the side menu")
press(game, "a")
local side = game.stack:top()
check(side ~= list, "A on the entry pushed the DATA/CRY/AREA/QUIT menu")
return list, side
end
-- ---------------------------------------------------------------- QUIT
local game = newGame()
local list, side = openSideMenu(game)
-- the start menu popped itself when POKéDEX was chosen (Menu pops before
-- onSelect unless the row is keepOpen), so this is dex list + side menu
eq(#game.stack.states, 2, "dex list and side menu are what is on the stack")
eq(side.items[#side.items].label, "QUIT", "QUIT is the last side-menu row")
side.index = #side.items
press(game, "a")
eq(#game.stack.states, 1,
"QUIT unwound the side menu and the dex list (" .. stackIds(game) .. ")")
local top = game.stack:top()
eq(top.screenId, "StartMenu", "QUIT lands back on the start menu")
check(top ~= list, "the dex list is not what is left on the stack")
eq(top.items[top.index].label, "POKéDEX",
"the start menu cursor is still on POKéDEX (wBattleAndStartSavedMenuItem)")
-- ---------------------------------------------------------------- B
-- The control case: B is b=2, which re-shows the list rather than leaving.
-- If QUIT and B ever behave the same again, one of these two fails.
local bGame = newGame()
local bList = select(1, openSideMenu(bGame))
press(bGame, "b")
eq(#bGame.stack.states, 1, "B off the side menu keeps the dex list up")
eq(bGame.stack:top(), bList, "B returns to the dex list, not the start menu")
T.finish("pokedex_quit_bug571")
+32 -4
View File
@@ -2,7 +2,10 @@
-- screen that captured it is still steering by that map (#510). Swapping A
-- and B used to close the screen mid-swap, because Input:applyBindings ran
-- inside BindingsMenu:storeBinding and turned the player's next confirm
-- press into a cancel. No pokered cite: rebinding is port-only (gap C2).
-- press into a cancel. A capture commits on the RELEASE of its press, a
-- second held input cancels it, and a captured input that another row owns
-- swaps rather than steals (#589). No pokered cite: rebinding is
-- port-only (gap C2).
-- luajit tests/engine/rebind_capture_bug510.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
@@ -73,19 +76,29 @@ eq(game.wroteOptions, 0, "and does not touch options on disk")
bm.index = ROW_B
press(bm, "a")
bm:onKeyPressed("z")
eq(game.save.options.bindings.b.key, "z", "the capture stores B = Z")
eq(bm.items[ROW_B].right, "Z", "the row shows the new key straight away")
check(game.save.options.bindings == nil,
"a capture holds its press; nothing stores before the release (#589)")
bm:onKeyReleased("z")
eq(game.save.options.bindings.b.key, "z", "releasing the press stores B = Z")
eq(bm.items[ROW_B].right, "Z/B", "the row shows the new key straight away")
eq(game.wroteOptions, 1, "the choice persists immediately")
eq(Input.keyBindings["z"], "a",
"but the live map still reads Z as A while the screen is open (#510)")
eq(#game.stack.states, 1, "capturing Z does not close the screen")
-- Z was the A row's effective key, so the steal became a swap: the A row
-- inherits B's previous key and no key serves two rows (#589)
eq(game.save.options.bindings.a.key, "x",
"capturing A's key for B hands A the old B key")
eq(bm.items[ROW_A].right, "X/A", "and the A row redraws with it")
-- the next Z the player presses is still confirm, so the A row can be armed
bm.index = ROW_A
press(bm, "a")
eq(bm.capture, bm.items[ROW_A], "the A row arms instead of the screen closing")
bm:onKeyPressed("x")
eq(game.save.options.bindings.a.key, "x", "the swap's other half stores")
bm:onKeyReleased("x")
eq(game.save.options.bindings.a.key, "x", "re-capturing A's own key keeps it")
eq(Input.keyBindings["x"], "b", "and X is still cancel until the screen closes")
-- closing commits both halves at once, through ListMenu's onCancel
@@ -107,8 +120,23 @@ local padBm = openMenu(padGame)
padBm.index = ROW_B
press(padBm, "a")
padBm:onGamepadPressed("y")
padBm:onGamepadReleased("y")
eq(padGame.save.options.bindings.b.pad, "y", "a pad capture stores")
eq(Input.padBindings["y"], nil, "and stays out of the live pad map until close")
-- a second input going down while the first is held backs the capture out
-- with no keyboard in reach, the pad's Escape (#589)
padBm.index = ROW_A
press(padBm, "a")
padBm:onGamepadPressed("x")
padBm:onGamepadPressed("b")
check(padBm.capture == nil, "a second press cancels the armed capture")
-- Game only calls the hook while it is armed; the straggling release of
-- the first button reaches a disarmed menu and stores nothing
if padBm.onGamepadReleased then padBm:onGamepadReleased("x") end
eq(padGame.save.options.bindings.a, nil,
"a cancelled capture's release writes no binding")
press(padBm, "b")
eq(Input.padBindings["y"], "b", "closing commits the pad half too")
+169
View File
@@ -0,0 +1,169 @@
-- CONTROLS rebinding, the #589 feature set beyond the capture deferral that
-- tests/engine/rebind_capture_bug510.lua pins: a captured pad button another
-- row effectively owns SWAPS with that row (no input serves two rows, no row
-- goes empty), a second input of either kind cancels an armed capture,
-- SELECT forgets one row's rebind so it falls back to the default, and START
-- confirms then drops the whole overlay. Everything is driven through the
-- entry points Game routes raw input to (onKeyPressed/onKeyReleased/
-- onGamepadPressed/onGamepadReleased) and through update() for the menu
-- keys. No pokered cite: rebinding is port-only (gap C2).
-- luajit tests/engine/rebind_swap_clear_bug589.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local Input = require("src.core.Input")
local Strings = require("src.core.Strings")
local BindingsMenu = require("src.ui.BindingsMenu")
-- same doubles as rebind_capture_bug510: a stack the menu can pop itself
-- off and an input whose queue is one fixed step of edges. data = {} keeps
-- ChoiceBox's un-guarded Sound.play on the headless no-audio path.
local function newGame()
local game = { save = { options = {} }, data = {}, wroteOptions = 0 }
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() self.wroteOptions = self.wroteOptions + 1 end
return game
end
local function press(state, btn)
state.game.input.queue = { [btn] = true }
state:update(1 / 60)
state.game.input.queue = {}
end
-- rows are BindingsMenu's BUTTONS order
local ROW_A, ROW_B, ROW_SELECT = 5, 6, 8
-- ---- (a) pad capture swaps with the row that owns the button --------------
Input:init()
local game = newGame()
local bm = BindingsMenu.new(game)
game.stack:push(bm)
bm.index = ROW_A
press(bm, "a")
eq(bm.capture, bm.items[ROW_A], "A arms the A row")
bm:onGamepadPressed("b")
check(game.save.options.bindings == nil,
"a pad capture holds its press; nothing stores before the release")
bm:onGamepadReleased("b")
local bindings = game.save.options.bindings
eq(bindings.a.pad, "b", "releasing pad B stores it on the A row")
eq(bindings.b.pad, "a",
"and the B row, which owned pad B, inherits the A row's old pad (#589)")
eq(bm.items[ROW_A].right, "Z/B", "the A row redraws with the new pad")
eq(bm.items[ROW_B].right, "X/A", "so does the B row")
-- after applying, every row's effective pad is unique and none is lost
Input:applyBindings(bindings)
eq(Input.padBindings["b"], "a", "applied: pad B is action A")
eq(Input.padBindings["a"], "b", "applied: pad A is action B")
local seen, actions = {}, {}
for button, action in pairs(Input.padBindings) do
check(not seen[action], "no action is reachable from two pad buttons: "
.. tostring(action))
seen[action] = button
actions[#actions + 1] = action
end
eq(#actions, 8, "all eight actions still have exactly one pad button")
Input:init()
-- ---- (b) a second input while the first is held cancels -------------------
-- key then key: the straggling release of the first press writes nothing
bm.index = ROW_SELECT
press(bm, "a")
bm:onKeyPressed("q")
bm:onKeyPressed("w")
check(bm.capture == nil, "a second key cancels the armed capture")
if bm.onKeyReleased then bm:onKeyReleased("q") end
check(bindings.select == nil, "the cancelled key capture wrote nothing")
-- key then pad: cancel crosses input kinds too
press(bm, "a")
bm:onKeyPressed("q")
bm:onGamepadPressed("x")
check(bm.capture == nil, "a pad press cancels a held key capture")
if bm.onKeyReleased then bm:onKeyReleased("q") end
if bm.onGamepadReleased then bm:onGamepadReleased("x") end
check(bindings.select == nil, "and still nothing stored")
local writesAfterSwap = game.wroteOptions
-- ---- (c) commit happens on release, not press -----------------------------
press(bm, "a")
bm:onKeyPressed("q")
check(bindings.select == nil, "press alone commits nothing (#589)")
eq(game.wroteOptions, writesAfterSwap, "and touches nothing on disk")
bm:onKeyReleased("q")
eq(bindings.select.key, "q", "the release is the commit")
eq(bm.items[ROW_SELECT].right, "Q/BACK", "the row shows the new key")
-- ---- (d) SELECT on a row forgets its rebind -------------------------------
press(bm, "select")
check(bindings.select == nil, "SELECT drops the row's overlay entry (#589)")
eq(bm.items[ROW_SELECT].right, "TAB/BACK", "the row falls back to the default")
-- the swapped B row clears the same way; a second SELECT on the now
-- default row is a no-op, not a write
local writes = game.wroteOptions
bm.index = ROW_B
press(bm, "select")
eq(game.wroteOptions, writes + 1, "clearing the swapped B row writes once")
check(game.save.options.bindings.b == nil, "and drops its overlay entry")
press(bm, "select")
eq(game.wroteOptions, writes + 1, "clearing an already-default row writes nothing")
-- ---- (e) START confirms, then drops the whole overlay ---------------------
-- rebuild a dirty overlay to reset
bm.index = ROW_A
press(bm, "a")
bm:onKeyPressed("p")
bm:onKeyReleased("p")
eq(game.save.options.bindings.a.key, "p", "fixture rebind in place")
press(bm, "start")
local box = game.stack:top()
check(box ~= bm, "START pushes the confirm box instead of resetting outright")
eq(bm.footer, Strings("RESET ALL BINDINGS?"),
"the footer doubles as the prompt")
-- the box starts on NO: a bare A press must keep the overlay
press(box, "a")
eq(game.stack:top(), bm, "answering pops the box")
check(game.save.options.bindings ~= nil, "NO keeps the bindings (defaultNo)")
eq(bm.items[ROW_A].right, "P/B", "and the rows keep showing them")
-- again, flip to YES: the overlay goes away and every row reads default
press(bm, "start")
box = game.stack:top()
press(box, "up")
press(box, "a")
check(game.save.options.bindings == nil, "YES clears options.bindings (#589)")
eq(bm.items[ROW_A].right, "Z/A", "the A row reads its default again")
eq(bm.items[ROW_B].right, "X/B", "so does the B row the swap had touched")
-- closing after the reset leaves the live map at the defaults
press(bm, "b")
eq(#game.stack.states, 0, "B closes the screen")
eq(Input.keyBindings["z"], "a", "the live map is back to Z = A")
eq(Input.padBindings["b"], "b", "and pad B = B")
Input:init()
T.finish("rebind_swap_clear_bug589")
+62
View File
@@ -0,0 +1,62 @@
-- Unit coverage for the render.compose seam (D14: a public-API test names
-- the hook, gate_hooks supplies the no-mod parity, docs/modding.md documents
-- it). render.compose lets a mod take over window composition: a wrap that
-- returns true without calling next owns the whole window; a wrap that calls
-- next lets the engine's normal single-window composite run and decorates it.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local Hooks = require("src.mods.Hooks")
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
local bus = Hooks.new()
Runtime.hooks = bus
-- the shape Renderer:endFrame hands the hook
local function fakeCtx()
return {
renderer = {}, worldCanvas = {}, uiCanvas = {},
worldActive = true, zones = {}, worldZones = nil,
ww = 480, wh = 432, ox = 0, oy = 12, scale = 3,
dpiX = 1, dpiY = 1, secondScreen = {},
}
end
-- takeover: a mod drawing its own layout returns true and never calls next,
-- so the engine's vanilla composite is skipped entirely
do
local vanillaRan, gotCtx = false, nil
bus:wrap("render.compose", function(next, renderer, ctx)
gotCtx = ctx
return true
end, 0, "ds-mod")
local handled = Runtime.call("render.compose",
function() vanillaRan = true; return false end,
{ tag = "renderer" }, fakeCtx())
T.eq(handled, true, "a mod returning true signals full window takeover")
T.eq(vanillaRan, false, "takeover skips the engine composite (vanilla not run)")
T.check(gotCtx ~= nil and gotCtx.ww == 480 and gotCtx.secondScreen ~= nil,
"the hook receives the frame ctx (metrics, canvases, secondScreen)")
bus.chains["render.compose"] = nil
end
-- decorate: a mod calling next lets the engine composite run, and the
-- engine's not-handled return (false) flows back through the chain
do
local vanillaRan = false
bus:wrap("render.compose", function(next, renderer, ctx)
return next()
end, 0, "ds-mod")
local handled = Runtime.call("render.compose",
function() vanillaRan = true; return false end,
{ tag = "renderer" }, fakeCtx())
T.eq(vanillaRan, true, "calling next runs the engine composite")
T.eq(handled, false, "the engine's not-handled return flows back through next")
bus.chains["render.compose"] = nil
end
Runtime.events, Runtime.hooks = savedEvents, savedHooks
T.finish("render_compose_seam")
+197
View File
@@ -0,0 +1,197 @@
-- Soft reset: A+B+SELECT+START held together drops the game back to the
-- title from anywhere, mid-battle included (#563). _Joypad
-- (engine/joypad.asm:6) tests the raw read with `cp PAD_BUTTONS` -- an
-- equality, so a d-pad direction in the mix cancels it -- and does so ahead
-- of the wJoyIgnore / BIT_DISABLE_JOYPAD masking further down, which is why
-- the combo still works where ordinary input is being thrown away.
-- TrySoftReset then decrements hSoftReset, seeded with 16 by Init
-- (home/init.asm:81), one poll at a time. The on-screen overlay's half is
-- here too: one finger only ever claims one control.
-- luajit tests/engine/soft_reset_bug563.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local Input = require("src.core.Input")
local TouchControls = require("src.core.TouchControls")
local Game = require("src.core.Game")
-- pokered's 16 polls; the port counts fixed steps, which are the same 60Hz
local HOLD_STEPS = 16
-- The keys the hand-off tells a player to press. Pinned because the combo
-- is only reachable if all four GB buttons have a key, and because the
-- letter A is bound to LEFT here -- a player reaching for "A" on the
-- keyboard adds a direction and cancels the chord instead of arming it.
Input:init()
eq(Input.keyBindings["z"], "a", "Z is the A button")
eq(Input.keyBindings["x"], "b", "X is the B button")
eq(Input.keyBindings["escape"], "start", "Escape is START")
eq(Input.keyBindings["tab"], "select", "Tab is SELECT")
eq(Input.keyBindings["a"], "left", "the letter A is LEFT, not the A button")
-- ---- the chord itself --------------------------------------------------
local function holdKeys(...)
Input:init()
for _, key in ipairs({ ... }) do Input:keypressed(key) end
end
holdKeys("z", "x", "escape", "tab")
check(Input:softResetHeld(), "all four buttons down arms the combo")
for _, missing in ipairs({ "z", "x", "escape", "tab" }) do
local keys = {}
for _, key in ipairs({ "z", "x", "escape", "tab" }) do
if key ~= missing then keys[#keys + 1] = key end
end
holdKeys(unpack(keys))
check(not Input:softResetHeld(),
"three of the four is not the combo (without " .. missing .. ")")
end
-- `cp PAD_BUTTONS` is an equality: any direction held alongside the four
-- takes the raw read off PAD_BUTTONS and TrySoftReset is never reached.
for _, dir in ipairs({ "up", "down", "left", "right" }) do
holdKeys("z", "x", "escape", "tab", dir)
check(not Input:softResetHeld(), dir .. " held alongside cancels the combo")
end
-- ---- the 16-step countdown ---------------------------------------------
-- one Game:step's worth of the chord; returns whether the reset fired
local function stepChord()
Input:step()
return Input:softResetStep()
end
holdKeys("z", "x", "escape", "tab")
for i = 1, HOLD_STEPS - 1 do
check(not stepChord(), "step " .. i .. " of the hold does not reset yet")
end
check(stepChord(), "the " .. HOLD_STEPS .. "th consecutive step resets")
-- hSoftReset is never re-seeded on release in the original, so its count
-- leaks across a session; the port re-arms instead, or a session's worth of
-- stray four-button presses would eventually add up to a reset.
holdKeys("z", "x", "escape", "tab")
for _ = 1, HOLD_STEPS - 1 do stepChord() end
Input:keyreleased("tab")
check(not stepChord(), "lifting SELECT one step short cancels")
Input:keypressed("tab")
for i = 1, HOLD_STEPS - 1 do
check(not stepChord(),
"re-pressing it restarts the count from 16, not from 1 (step " .. i .. ")")
end
check(stepChord(), "and a full fresh hold resets")
-- same re-arm for the direction case: a thumb brushing the d-pad mid-hold
holdKeys("z", "x", "escape", "tab")
for _ = 1, HOLD_STEPS - 1 do stepChord() end
Input:keypressed("up")
check(not stepChord(), "a direction one step short cancels")
Input:keyreleased("up")
for _ = 1, HOLD_STEPS - 1 do
check(not stepChord(), "and the count starts over")
end
check(stepChord(), "reaching 16 again from the restart")
-- ---- Game:step routes it above the state stack --------------------------
-- Doubles for the two services Game:step touches on the reset path. The
-- real stack is not needed: what is being pinned is that the combo is read
-- before stack:update, so it fires from a battle, a menu or a cutscene and
-- not just from the overworld (#563).
local function fakeGame()
local game = { input = Input, save = {}, returned = 0 }
game.stack = {
updates = 0,
update = function(self) self.updates = self.updates + 1 end,
}
function game:returnToTitle() self.returned = self.returned + 1 end
return game
end
local game = fakeGame()
holdKeys("z", "x", "escape", "tab")
for _ = 1, HOLD_STEPS do Game.step(game, 1 / 60) end
eq(game.returned, 1, "Game:step falls back to the title on the 16th step")
eq(game.stack.updates, HOLD_STEPS - 1,
"and the state on top never gets that step, whatever it was")
check(not Input:isDown("a"),
"the still-physically-held A is cleared, so the title screen does not "
.. "read it as a menu choice on its first frame")
-- A tool mod may hand Game a stand-in input; Game guards on the method
-- existing rather than assuming it, so those simply never soft reset.
local bare = fakeGame()
bare.input = { step = function() end, isDown = function() return false end }
for _ = 1, HOLD_STEPS * 2 do Game.step(bare, 1 / 60) end
eq(bare.returned, 0, "an input with no chord bookkeeping never resets")
-- ---- the on-screen overlay ---------------------------------------------
-- Force the overlay live the way a phone would have it. img is only ever
-- tested for truthiness on the input path (drawing is what reads it), so a
-- placeholder keeps this suite off love.graphics.newImage.
Input:init()
TouchControls:init()
TouchControls.active, TouchControls.enabled = true, true
TouchControls.img = { stub = true }
local L = TouchControls:layout()
-- One finger, one control: touchpressed returns on its first hit, so no
-- single touch can ever arm more than a quarter of the chord however the
-- layout editor has moved the controls around.
for _, btn in ipairs({ "a", "b", "start", "select" }) do
Input:init()
TouchControls:reset()
TouchControls:touchpressed(1, L[btn].cx, L[btn].cy)
local held = 0
for _ in pairs(TouchControls.held) do held = held + 1 end
eq(held, 1, "a finger on " .. btn:upper() .. " presses that button alone")
check(not Input:softResetHeld(), "...which is not the combo")
end
-- Four fingers on four controls is the only way there, and it still has to
-- survive the same 16 steps -- better than a quarter second of everything
-- staying put.
Input:init()
TouchControls:reset()
local ids = { a = 1, b = 2, start = 3, select = 4 }
for btn, id in pairs(ids) do
TouchControls:touchpressed(id, L[btn].cx, L[btn].cy)
end
check(Input:softResetHeld(), "four fingers on A, B, START and SELECT arm it")
for i = 1, HOLD_STEPS - 1 do
check(not stepChord(), "the overlay holds the same countdown (step " .. i .. ")")
end
check(stepChord(), "and resets on the 16th")
-- the accidental version: one finger slips off part way through
Input:init()
TouchControls:reset()
for btn, id in pairs(ids) do
TouchControls:touchpressed(id, L[btn].cx, L[btn].cy)
end
for _ = 1, HOLD_STEPS - 1 do stepChord() end
TouchControls:touchreleased(ids.select, L.select.cx, L.select.cy)
check(not stepChord(), "a finger leaving SELECT one step short cancels")
check(not Input:softResetHeld(), "and the chord is no longer armed")
-- the fifth finger a real two-handed grip has on the d-pad
Input:init()
TouchControls:reset()
for btn, id in pairs(ids) do
TouchControls:touchpressed(id, L[btn].cx, L[btn].cy)
end
TouchControls:touchpressed(5, L.dpad.cx, L.dpad.cy - L.dpad.w * 0.4)
check(not Input:softResetHeld(),
"a thumb on the d-pad cancels it the same way a keyboard direction does")
Input:init()
TouchControls:reset()
T.finish("soft_reset_bug563")
+107
View File
@@ -0,0 +1,107 @@
-- A beaten trainer's own line is spoken, so it carries their name tag.
--
-- TrainerEndBattleText (home/trainers.asm:381-386) prints _TrainerNameText
-- -- wNameBuffer then ": " (data/text/text_1.asm:16-19) -- and only then
-- the saved EndBattleText pointer, so the loss line opens
-- "BUG CATCHER: ...". The port printed the pointer alone, leaving an
-- unattributed sentence on the battle screen (#566). SaveTrainerName reads
-- TrainerNamePointers, whose RIVAL1/2/3 entries aim at wTrainerName, so the
-- rival tags with his name and never with the class id.
--
-- The tag belongs to the box, not to every page: a `para` inside the loss
-- text is a second page and prints untagged.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Font = require("src.render.Font")
Font.load(Data)
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local BattleState = require("src.battle.BattleState")
local function freshGame()
local save = SaveData.newGame()
save.player.name = "RED"
save.player.rival = "GARY"
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
return { data = Data, save = save,
stack = { top = function() return nil end,
push = function() end, pop = function() end } }
end
-- KO the whole enemy party and take the win branch of enemyMonFainted
local function beat(game, oppClass, endText)
local battle = BattleState.newTrainer(game, oppClass, 1)
battle.participants = {}
battle.playVictoryMusic = function() end
battle.endBattleText = endText
for _, mon in ipairs(battle.enemyParty) do mon.hp = 0 end
battle.enemyIndex = #battle.enemyParty
battle.enemy.mon = battle.enemyParty[battle.enemyIndex]
battle:enemyMonFainted()
local texts = {}
for _, row in ipairs(battle.queue) do
if row.text then texts[#texts + 1] = row.text end
end
return battle, texts
end
-- index of the first queued message containing `needle`
local function findText(texts, needle)
for i, text in ipairs(texts) do
if text:find(needle, 1, true) then return i end
end
end
do
local game = freshGame()
local battle, texts = beat(game, "OPP_FIX_YOUNGSTER",
"Well fixed!\fBut I will win next time.")
T.eq(battle.result, "win", "the last enemy mon fainting ends the battle")
local loss = findText(texts, "Well fixed!")
T.check(loss ~= nil, "the trainer's loss line is queued")
T.eq(texts[loss], battle.trainer.name .. ": Well fixed!",
"the loss line opens with the trainer class and a colon")
T.eq(texts[loss + 1], "But I will win next time.",
"the `para` page is a page of the same box, so it is not tagged again")
-- TrainerBattleVictory (core.asm:915-949) order: TrainerDefeatedText,
-- the pic scroll, PrintEndBattleText, then MoneyForWinningText
local defeated = findText(texts, "defeated")
local money = findText(texts, "for winning")
T.check(defeated and defeated < loss, "the tag follows 'RED defeated ...'")
T.check(money and money > loss + 1, "the prize line still comes last")
end
-- RIVAL1/2/3 tag with the rival's name, not the class id: newTrainer
-- overlays wRivalName the way GetTrainerName_ does.
do
local game = freshGame()
Data.trainers.OPP_RIVAL1 = {
id = "OPP_RIVAL1", index = 2, name = "RIVAL1", baseMoney = 15,
parties = { { { level = 5, species = "FIXMON_C" } } },
}
local battle, texts = beat(game, "OPP_RIVAL1", "You're still learning!")
T.eq(battle.trainer.name, "GARY", "the rival battles under his own name")
local loss = findText(texts, "still learning")
T.check(loss ~= nil, "the rival's loss line is queued")
T.eq(texts[loss], "GARY: You're still learning!",
"the rival tag is his name, never the RIVAL1 class id")
end
-- A scripted battle that prints its own follow-up leaves endBattleText nil;
-- nothing may invent a tagged empty box for it.
do
local game = freshGame()
local _, texts = beat(game, "OPP_FIX_YOUNGSTER", nil)
for _, text in ipairs(texts) do
T.check(not text:match("^FIX YOUNGSTER: %s*$"),
"no empty tagged box when the trainer has no loss line")
end
end
T.finish("trainer loss line bug566")
@@ -0,0 +1,80 @@
-- SHIFT's "about to use" offer is one box with a CONT, not two boxes.
--
-- _TrainerAboutToUseText (data/text/text_2.asm:911-921) is
-- "<wTrainerName> is" / line "about to use" / cont "<wEnemyMonNick>!": the
-- cont scrolls "X is" off the top so the nick arrives UNDER the words that
-- explain it. Splitting that into two say() rows put the nick alone on a
-- fresh page, which reads as a bare Pokemon name with no sentence around
-- it (#565).
--
-- The rendered half is checked through BattleState:startMessage, the same
-- parser the battle box types from, so this fails if \v ever stops meaning
-- ContText there.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Font = require("src.render.Font")
Font.load(Data)
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local BattleState = require("src.battle.BattleState")
-- SHIFT only offers the switch when the player has more than one party
-- slot and the active mon is alive (core.asm:1366-1443)
local save = SaveData.newGame()
save.player.name = "RED"
save.party = { Pokemon.new(Data, "FIXMON_A", 30), Pokemon.new(Data, "FIXMON_B", 30) }
save.options = save.options or {}
save.options.battleStyle = "shift"
local game = { data = Data, save = save,
stack = { top = function() return nil end,
push = function() end, pop = function() end } }
local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
T.check(#battle.enemyParty >= 2, "the fixture trainer has a reserve to send out")
battle.participants = {}
-- KO the lead so enemyMonFainted queues the send-out for slot 2
battle.enemyParty[1].hp = 0
battle.enemy.mon = battle.enemyParty[1]
battle:enemyMonFainted()
local texts = {}
for _, row in ipairs(battle.queue) do
if row.text then texts[#texts + 1] = row.text end
end
local offer
for _, text in ipairs(texts) do
if text:find("about to use", 1, true) then offer = text end
end
T.check(offer ~= nil, "SHIFT queues the about-to-use offer")
local nick = Data.pokemon[battle.enemyParty[2].species].name
T.eq(offer, battle.trainer.name .. " is\nabout to use\v" .. nick .. "!",
"one box: name, the line break, then a CONT to the nick")
-- the regression itself: the nick must never be a message of its own
for _, text in ipairs(texts) do
T.neq(text, nick .. "!", "no bare-nickname box follows the offer")
end
-- and what the box actually renders: three lines, the third scrolled in,
-- so the two visible rows when the nick lands are "about to use" / nick
battle:startMessage({ text = offer })
T.eq(#battle.lines, 3, "the offer types as three lines")
T.check(not battle.lines[2].cont, "line 2 follows a plain line break")
-- guarded so a regression reports the missing line instead of dying on it
if battle.lines[3] then
T.check(battle.lines[3].cont, "line 3 is a ContText scroll, not a new page")
T.same(battle.lines[2].codes, Font.encode("about to use"),
"the line above the nick still says 'about to use'")
T.same(battle.lines[3].codes, Font.encode(nick .. "!"),
"the scrolled-in line is the nick")
end
T.finish("trainer shift prompt bug565")
+156
View File
@@ -0,0 +1,156 @@
-- A tap on the d-pad turns in place; only a held direction steps (#415).
-- home/overworld.asm .handleDirectionButtonPress reaches the turn only while
-- wCheckFor180DegreeTurn is set, and .noDirectionButtonsPressed -- the poll
-- that finds nothing held -- is the one place that sets it. So the turn
-- delay is spent once per press, not at every facing change: a direction
-- swapped mid-walk keeps stepping. Player.turnArmed is that flag and
-- Player:turnWindow is how long the fresh turn holds the step off.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Collision = require("src.world.Collision")
local FieldDefaults = require("src.world.FieldDefaults")
local Input = require("src.core.Input")
local Player = require("src.world.Player")
Collision.load(Data)
-- the fixture dataset ships one player sheet under its own id; Player.new
-- builds a SpriteRenderer off whatever field.playerSprites names
Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" }
-- Collision.canMove only ever asks a map these four things, so open floor
-- is cheaper to state directly than to stand a real MapLoader up (that
-- pulls the tileset atlas in, which is the asset pipeline, not this).
local map = {
def = { tileset = "FIX_OUT" },
inBounds = function(_, x, y) return x >= 0 and y >= 0 and x < 20 and y < 20 end,
isWalkableCell = function() return true end,
isWaterCell = function() return false end,
cellTile = function() return 0 end,
}
local function newPlayer(facing)
local p = Player.new(Data, 5, 5, facing or "down")
Input:init()
local Game = require("src.core.Game")
Game.input = Input
return p
end
-- One fixed step of the overworld's input half: OverworldState:handleInput
-- polls the held direction and re-arms turnArmed when it finds none, then
-- Player:update ticks turnTimer down. `dir` nil is a released d-pad.
local function step(p, dir)
local result
if dir then
result = p:tryMove(dir, map, {})
else
p.turnArmed = true
end
p:update()
return result
end
-- Hold `dir` from a standstill until the step it starts lands, and stop on
-- the landing poll: one more held poll would chain straight into the next
-- tile, which is the walk this suite is not measuring.
local function walkOneTile(p, dir)
local fromX, fromY = p.cellX, p.cellY
for _ = 1, 120 do
step(p, dir)
if not p.moving and (p.cellX ~= fromX or p.cellY ~= fromY) then
return true
end
end
return false
end
-- ---- the tap: one poll with the direction down, then nothing -------------
do
local p = newPlayer("down")
T.eq(step(p, "up"), "turned", "the first poll of a fresh press turns")
T.eq(p.facing, "up", "and the facing lands on the tapped direction")
for _ = 1, 20 do step(p, nil) end
T.eq(p.cellY, 5, "a released d-pad never commits the step")
T.eq(p.cellX, 5, "and never drifts sideways either")
T.eq(p.facing, "up", "the turn survives the release")
end
-- ---- the hold: the same press kept down steps once the window closes -----
do
local window = FieldDefaults.world(Data, "turnFrames")
T.eq(window, 4, "the dataset's turn window is 4 fixed steps")
-- one short of the window: still turning, still on the tile
local short = newPlayer("down")
T.eq(step(short, "up"), "turned", "hold: the first poll turns")
for _ = 1, window - 1 do
T.eq(step(short, "up"), nil, "a hold inside the window takes no step")
end
T.eq(short.cellY, 5, "so the tile is unchanged after " .. window .. " polls")
-- the poll after the window closes is the one that moves
T.eq(step(short, "up"), "moved", "the poll past the window commits the step")
T.check(short.moving, "and the step animation is running")
end
-- ---- the corner: a direction swapped without ever letting go -------------
--
-- This is what turnArmed buys. Walking a corner never lifts the d-pad, so
-- .noDirectionButtonsPressed never runs and the original charges no turn
-- delay for the new facing; gating on the timer alone stalled every corner.
do
local p = newPlayer("down")
T.check(walkOneTile(p, "up"), "the held direction walks a whole tile")
T.eq(p.cellY, 4, "one tile north")
local turned = step(p, "right") -- swap without a release in between
T.eq(p.facing, "right", "the swap changes the facing")
T.eq(turned, "moved", "and steps straight away: no second turn delay")
T.eq(p.turnTimer, 0, "the corner charges no turn window")
end
-- ---- re-arming: the flag only comes back from a standstill ---------------
do
local p = newPlayer("down")
T.eq(p.turnArmed, true, "a fresh player starts armed")
step(p, "left")
T.eq(p.turnArmed, false, "the turn spends the arm")
step(p, "left")
T.eq(p.turnArmed, false, "a still-held direction does not re-arm it")
step(p, nil)
T.eq(p.turnArmed, true, "the poll that finds nothing held re-arms it")
end
-- ---- the on-screen d-pad gets the longer window -------------------------
--
-- A finger cannot produce a 4-frame press, so Player:turnWindow widens it
-- for the overlay only. Keyed on the live source (Input:isTouchDown), not
-- on whether the overlay is visible, so a phone with a pad plugged in keeps
-- the physical window.
do
local p = newPlayer("down")
p.facing = "up"
T.eq(p:turnWindow(), 4, "keyboard and pad presses get the short window")
Input:overlayPressed("up")
Input:step()
T.check(Input:isTouchDown("up"), "the overlay registers as a touch source")
T.eq(p:turnWindow(), 8, "an overlay press gets the long window")
Input:keypressed("up")
Input:overlayReleased("up")
Input:step()
T.check(Input:isDown("up"), "the keyboard still holds the direction")
T.check(not Input:isTouchDown("up"), "but the touch source is gone")
T.eq(p:turnWindow(), 4, "so the window drops back to the short one")
end
T.finish("turn in place")
+180
View File
@@ -0,0 +1,180 @@
-- BATTLE LAYOUT = WIDE, screen shakes (#562).
--
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window
-- PredefShakeScreenVertically / PredefShakeScreenHorizontally /
-- AnimationShakeScreenHorizontallySlow displace IS the whole screen: pics,
-- both HUDs and the message window move together, which is what
-- BattleState:drawClassic does by offsetting its whole BG canvas. The wide
-- composition used to apply fx.shakeX/shakeY to the two pic regions only, so
-- an applying-attack shake slid the monsters across a nailed-down HUD and
-- text box -- TAIL WHIP's slow 3px creep (wAnimationType 6) being the case
-- the report named.
--
-- Headless: WideBattle.draw is played into a recording love.graphics and the
-- Font / HudTiles entry points, so what is asserted is where each piece of
-- the composition landed, not pixels.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local WideBattle = require("src.battle.WideBattle")
local Font = require("src.render.Font")
local HudTiles = require("src.render.HudTiles")
-- ---------------------------------------------------------------- recorder
-- The stub's push/pop keep no transform, so the translate stack is tracked
-- here: `tx, ty` is the offset in force when a draw call is issued. Font and
-- HudTiles are patched in place (WideBattle captured these very tables at
-- require time, so swapping package.loaded would not reach it).
local g = love.graphics
local realPush, realPop = g.push, g.pop
local tx, ty, stack = 0, 0, {}
local scissors, marks
g.push = function(...) stack[#stack + 1] = { tx, ty }; realPush(...) end
g.pop = function()
local s = table.remove(stack)
if s then tx, ty = s[1], s[2] end
realPop()
end
g.translate = function(dx, dy) tx, ty = tx + (dx or 0), ty + (dy or 0) end
-- restoreScissor calls setScissor() with no arguments to clear; only a real
-- rect is a region being opened
g.setScissor = function(x, y, w, h)
if x then scissors[#scissors + 1] = { x = x, y = y, w = w, h = h } end
end
local function mark(name) marks[#marks + 1] = { name = name, tx = tx, ty = ty } end
Font.drawBox = function() mark("box") end
Font.draw = function() mark("text") end
Font.drawCode = function() mark("code") end
Font.split = function() return {} end
Font.spansFitting = function() return math.huge end
HudTiles.tile = function() mark("tile") end
HudTiles.drawHPBar = function() mark("hpbar") end
-- A battle stripped to what the composition reads. drawPicsLayer and
-- drawAnimLayer only record the offset they were handed, which is the whole
-- question: are the two pic regions and everything else moving as one?
local function battleWith(fx, sprites)
return {
data = {},
frame = 0,
phase = "messages",
current = {},
shown = {},
fx = fx,
enemy = { name = "GENGAR", shownHP = 60,
mon = { level = 25, stats = { hp = 60 } } },
player = { name = "NIDORINO", shownHP = 55,
mon = { level = 24, stats = { hp = 55 } } },
animPlaying = sprites ~= nil,
animPlayer = sprites and { stepIndex = 1, steps = { { sprites = sprites } } },
drawPicsLayer = function() mark("pics") end,
drawAnimLayer = function() mark("anim") end,
growInScale = function() return nil end,
drawBallRow = function() end,
statusLabel = function() return "" end,
}
end
local function draw(fx, sprites)
tx, ty, stack = 0, 0, {}
scissors, marks = {}, {}
WideBattle.draw(battleWith(fx, sprites))
return scissors, marks
end
local function marksNamed(list, name)
local out = {}
for _, m in ipairs(list) do if m.name == name then out[#out + 1] = m end end
return out
end
-- Every piece of the composition drawn in wide-surface coordinates sits at
-- (dx, dy). The two pic passes and the anim layer carry their own anchor on
-- top of the shake and are checked against it by hand.
local SELF_ANCHORED = { pics = true, anim = true }
local function everythingAt(list, dx, dy, label)
local seen = 0
for _, m in ipairs(list) do
if not SELF_ANCHORED[m.name] then
seen = seen + 1
if m.tx ~= dx or m.ty ~= dy then
T.check(false, ("%s: %s drew at (%d, %d), wanted (%d, %d)")
:format(label, m.name, m.tx, m.ty, dx, dy))
return
end
end
end
T.check(seen >= 6,
label .. ": the HUDs, the status bars and the message box all drew")
T.check(true, ("%s: all %d pieces moved to (%d, %d) together")
:format(label, seen, dx, dy))
end
-- ------------------------------------------------------------ still screen
-- The baseline the shaken frames are measured against: the player's 160x144
-- region is inset 32px from the top of the wider field, the enemy's starts at
-- x=160, and both draw their classic-space pics at the wide anchors.
local s, m = draw(nil)
T.eq(s[1].x, 0, "unshaken, the player region clips from the left edge")
T.eq(s[1].y, 32, "unshaken, the player region starts below the foe's HUD")
T.eq(s[2].x, 160, "unshaken, the enemy region clips from the halfway mark")
T.eq(s[2].y, 0, "unshaken, the enemy region starts at the top")
local pics = marksNamed(m, "pics")
T.eq(#pics, 2, "one pics pass per side")
T.eq(pics[1].tx, 20, "unshaken, the player pic sits on the player anchor")
T.eq(pics[1].ty, 8, "unshaken, the player pic sits on the player baseline")
T.eq(pics[2].tx, 136, "unshaken, the enemy pic sits on the enemy anchor")
everythingAt(m, 0, 0, "no shake")
-- -------------------------------------------------- horizontal shake (#562)
-- TAIL WHIP is wAnimationType 6, AnimationShakeScreenHorizontallySlow b=3:
-- the screen creeps out to 3px and back, twice, in silence. Before the fix
-- the two pic regions took the 3px and drawHUDs / drawTextArea did not, so
-- the foe glided sideways over a stationary HUD and message box.
s, m = draw({ shakeX = 3, shakeY = 0 })
T.eq(s[1].x, 3, "a horizontal shake carries the player region's clip with it")
T.eq(s[2].x, 163, "...and the enemy region's")
pics = marksNamed(m, "pics")
T.eq(pics[1].tx, 23, "the player pic rides the shake")
T.eq(pics[2].tx, 139, "the enemy pic rides the shake")
everythingAt(m, 3, 0, "3px horizontal shake")
-- ---------------------------------------------------- vertical shake (#562)
-- wAnimationType 1 (PredefShakeScreenVertically b=8) drops the whole window.
-- The region rects move with their contents, so a pic pushed toward
-- FIELD_BOTTOM is not sheared off its own window edge on the way down.
s, m = draw({ shakeX = 0, shakeY = 8 })
T.eq(s[1].y, 40, "a vertical shake carries the player region's clip down")
T.eq(s[1].h, WideBattle.FIELD_BOTTOM - 32,
"the player region keeps its height, so the pic is not cropped short")
T.eq(s[2].y, 8, "the enemy region's clip drops the same 8px")
pics = marksNamed(m, "pics")
T.eq(pics[1].ty, 16, "the player pic drops with it")
everythingAt(m, 0, 8, "8px vertical shake")
-- --------------------------------------------------- the animations-off path
-- With ANIMATION = OFF an extracted anim that carries a shake flag falls back
-- to fx.shake, a +-2 alternation read off battle.frame; it moves the screen
-- through the same path.
s, m = draw({ shake = 24 })
T.eq(s[1].x, 2, "the animations-off fallback shakes the regions too")
everythingAt(m, 2, 0, "animations-off fallback")
-- ------------------------------------------------------- the OAM anim layer
-- Battle animation sprites are OAM, not BG, so the window shake never moves
-- them -- drawClassic draws its anim layer outside the offset for the same
-- reason. Their own offset is WideBattle.animationOffset, and that stays put
-- while the screen under them shakes.
local sprites = { { x = 104 }, { x = 144 } }
local ax, ay = WideBattle.animationOffset(sprites)
s, m = draw({ shakeX = 3, shakeY = 0 }, sprites)
local anim = marksNamed(m, "anim")
T.eq(#anim, 1, "the anim layer drew")
T.eq(anim[1].tx, ax, "the anim layer keeps its own anchor, not the shake")
T.eq(anim[1].ty, ay, "...on both axes")
T.eq(s[#s].x, 0, "the anim region spans the field from the left edge")
T.eq(s[#s].w, WideBattle.WIDTH, "...the full wide surface")
T.finish("wide battle shake")
+143
View File
@@ -0,0 +1,143 @@
-- Yellow's two extra pic rips and the plumbing that reaches for them.
--
-- #557: LoadPlayerBackPic (pokeyellow engine/battle/core.asm:6384-6391)
-- loads OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for
-- BATTLE_TYPE_PIKACHU, the Pallet Town catch scene, and DisplayBattleMenu
-- splits the displayed thrower name on the same wBattleType. The port
-- carries that split as makeOldManDemo's name argument.
--
-- #561: TalkToPikachu's framed portrait draws the chosen PikaPicAnimScript's
-- own base frame (data/pikachu/pikachu_pic_animation.asm), not the battle
-- front pic that stood in for all of them.
--
-- Both rips are gated on manifest symbols, so tools/rom_manifest_yellow.json
-- is as much a part of these fixes as the Lua is: without the symbol the
-- extractor writes nothing, the runtime existence check falls back, and the
-- screen looks exactly like the bug report.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local BattleState = require("src.battle.BattleState")
local FieldDefaults = require("src.world.FieldDefaults")
local Sprites = require("src.pokemon.Sprites")
local PROF_BACK = "assets/generated/battle/profoakb.png"
local OLDMAN_BACK = "assets/generated/battle/oldmanb.png"
local RED_BACK = "assets/generated/battle/redb.png"
local function readFile(path)
local handle = io.open(path, "r")
if not handle then return nil end
local text = handle:read("*a")
handle:close()
return text
end
-- ---------------------------------------------------------------------
-- #557: the back pic keys and the wBattleType split
-- ---------------------------------------------------------------------
T.eq(FieldDefaults.fieldValue(nil, "playerPics", "oakBack"), PROF_BACK,
"playerPics carries a third back pic for the PROF.OAK demo")
T.eq(FieldDefaults.fieldValue(nil, "playerPics", "demoBack"), OLDMAN_BACK,
"the old man's back pic is untouched")
-- The love stub's filesystem is a table of written files, so writing the
-- path IS "this cache was imported after the rip landed".
love.filesystem.write(PROF_BACK, "png")
T.eq(Sprites.playerPath(nil, "back", { demo = true, oakDemo = true }),
PROF_BACK, "the Pallet catch demo fights behind Oak's own back pic")
T.eq(Sprites.playerPath(nil, "back", { demo = true }), OLDMAN_BACK,
"the Route 5 catch tutorial still gets the old man")
T.eq(Sprites.playerPath(nil, "back", {}), RED_BACK,
"an ordinary battle still gets the player")
love.filesystem.remove(PROF_BACK)
T.eq(Sprites.playerPath(nil, "back", { demo = true, oakDemo = true }),
OLDMAN_BACK,
"a cache built before the rip falls back to the old man, not a missing file")
-- makeOldManDemo only fills fields when a player battler already exists, so
-- a stub with one stays clear of Pokemon.new and the fixture dataset.
local oak = { player = true }
BattleState.makeOldManDemo(oak, "PROF.OAK")
T.eq(oak.demoName, "PROF.OAK", "the Yellow demo names PROF.OAK as the thrower")
T.eq(oak.oakDemo, true, "and asks for his back pic")
local oldMan = { player = true }
BattleState.makeOldManDemo(oldMan)
T.eq(oldMan.demoName, "OLD MAN", "the unnamed demo is still the old man")
T.eq(oldMan.oakDemo, false, "and does not reach for Oak's pic")
-- ---------------------------------------------------------------------
-- the symbol tables the two rips are gated on
-- ---------------------------------------------------------------------
local extractor = readFile("src/import/RomExtractor.lua")
T.check(extractor ~= nil, "src/import/RomExtractor.lua is readable")
extractor = extractor or ""
local yellowManifest = readFile("tools/rom_manifest_yellow.json")
T.check(yellowManifest ~= nil, "tools/rom_manifest_yellow.json is readable")
yellowManifest = yellowManifest or ""
local generator = readFile("tools/make_yellow_manifest.py")
T.check(generator ~= nil, "tools/make_yellow_manifest.py is readable")
generator = generator or ""
-- Every symbol below is real in pokeyellow.sym; what decides whether the
-- extractor can see it is whether the generator injects it. Red never
-- referenced any of them, so make_yellow_manifest's Red-name remap cannot
-- pick them up on its own -- they have to be listed in YELLOW_EXTRA_SYMBOLS.
-- One line per group rather than per label: a missing symbol list reads as
-- one fault to fix, not fifty-odd.
local function missingFrom(text, labels)
local missing = {}
for _, label in ipairs(labels) do
if not text:find('"' .. label .. '"', 1, true) then
missing[#missing + 1] = label
end
end
return missing
end
local function requireSymbols(labels, why)
local gone = missingFrom(yellowManifest, labels)
T.check(#gone == 0, why .. ": rom_manifest_yellow.json is missing "
.. (#gone == 0 and "nothing" or table.concat(gone, " ")))
gone = missingFrom(generator, labels)
T.check(#gone == 0, why .. ": make_yellow_manifest.py never asks for "
.. (#gone == 0 and "nothing" or table.concat(gone, " "))
.. " (add them to YELLOW_EXTRA_SYMBOLS)")
end
T.check(extractor:find('self.symbols["ProfOakPicBack"]', 1, true) ~= nil,
"the extractor gates Oak's back pic on the ProfOakPicBack symbol")
requireSymbols({ "ProfOakPicBack" }, "#557")
-- Read the label list out of the extractor itself rather than repeating it:
-- the contract under test is that the manifest answers whatever
-- extractField asks for, so a later edit to PIKAPIC_BASE stays covered.
local pikapicBlock = extractor:match("local PIKAPIC_BASE = {(.-)\n }")
T.check(pikapicBlock ~= nil, "RomExtractor's PIKAPIC_BASE table is readable")
local pikapic, seen = {}, {}
for label in (pikapicBlock or ""):gmatch('"([%w_]+)"') do
if not seen[label] then
seen[label] = true
pikapic[#pikapic + 1] = label
end
end
-- 28 PikaPicAnimScripts, script 26 sharing script 11's base pic
T.eq(#pikapic, 27, "PIKAPIC_BASE names 27 distinct base pics")
requireSymbols(pikapic, "#561")
-- Red and Blue have neither pic, so neither manifest may grow one: a stray
-- entry there would rip Yellow art out of the wrong ROM.
for _, path in ipairs({ "tools/rom_manifest.json", "tools/rom_manifest_blue.json" }) do
local text = readFile(path)
T.check(text ~= nil, path .. " is readable")
T.check(text == nil or text:find("ProfOakPicBack", 1, true) == nil,
path .. " has no ProfOakPicBack, matching pokered")
end
T.finish("yellow oak back pic and pikapic bases")
+8
View File
@@ -203,4 +203,12 @@ stub.mouse = {
stub.timer = { getTime = function() return 0 end }
-- Desktop / headless: full-window safe area (matches LÖVE's fallback).
stub.window = {
getSafeArea = function()
local ww, wh = stub.graphics.getDimensions()
return 0, 0, ww, wh
end,
}
return stub
+62
View File
@@ -716,6 +716,68 @@ do
check(actBattle:enemyAction().hooked == true,
"battle.enemy_action hook rewrites the choice")
unsub()
-- battle.catch_exp: vanilla catches never grant exp; a mod can flip that
unsub = hooks:wrap("battle.catch_exp", function() return true end)
local catchExpParty = { Pokemon.new(Data, "BULBASAUR", 10) }
local catchExpGame = makeGame(catchExpParty)
local catchExpBattle = BattleState.newWild(catchExpGame, "RATTATA", 3)
catchExpBattle.enemy.mon = Pokemon.new(Data, "RATTATA", 3)
local expBeforeCatch = catchExpParty[1].exp
catchExpBattle:storeCaughtMon()
check(catchExpParty[1].exp > expBeforeCatch,
"battle.catch_exp hook pays out exp on a catch")
unsub()
-- battle.exp_award: a mod can replace the participant/EXP.ALL split
-- wholesale via ctx.applyShare
unsub = hooks:wrap("battle.exp_award", function(nextFn, ctx)
ctx.applyShare(ctx.alive[1], 999, "flatShare")
end)
local awardParty = { Pokemon.new(Data, "BULBASAUR", 10) }
local awardGame = makeGame(awardParty)
local awardBattle = BattleState.newWild(awardGame, "RATTATA", 3)
local expBeforeAward = awardParty[1].exp
awardBattle:awardExp()
check(awardParty[1].exp > expBeforeAward,
"battle.exp_award hook replaces the award split")
unsub()
end
-- ------- battle.low_health_alarm hook: mirrors the siren toggle
do
local Sound = require("src.core.Sound")
local calls = {}
local origStart, origStop = Sound.startLoop, Sound.stopLoop
Sound.startLoop = function(data, name) calls[#calls + 1] = { "start", name } end
Sound.stopLoop = function(name) calls[#calls + 1] = { "stop", name } end
local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) })
local battle = BattleState.newWild(game, "RATTATA", 5)
battle.player.mon.hp = 1
battle.player.shownHP = 1
local seenOn = nil
local unsub = hooks:wrap("battle.low_health_alarm", function(nextFn, ctx)
seenOn = ctx.on
return nextFn(ctx)
end)
battle:updateFx()
check(seenOn == true, "battle.low_health_alarm hook sees the alarm toggle on")
check(calls[#calls][1] == "start" and calls[#calls][2] == "Low_Health_Alarm",
"an unmodified hook still starts the siren loop")
unsub()
unsub = hooks:wrap("battle.low_health_alarm", function(nextFn, ctx)
ctx.on = false
return nextFn(ctx)
end)
battle:updateFx()
check(calls[#calls][1] == "stop", "a mod can force the alarm off before vanilla acts")
unsub()
Sound.startLoop, Sound.stopLoop = origStart, origStop
end
-- ------- battle events: the scripted sequence
+8 -6
View File
@@ -397,11 +397,11 @@ check(getmetatable(bm) == BindingsMenu,
check(bm.screenId == "BindingsMenu",
"the pushed rebind screen carries its screen id")
check(#bm.items == 8, "one row per logical button")
check(bm.items[1].label == "UP" and bm.items[1].right == "UP"
and bm.items[5].label == "A" and bm.items[5].right == "Z"
and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE"
check(bm.items[1].label == "UP" and bm.items[1].right == "UP/D-UP"
and bm.items[5].label == "A" and bm.items[5].right == "Z/A"
and bm.items[7].label == "START" and bm.items[7].right == "ESC/START"
and bm.items[8].label == "SELECT" and bm.items[8].right == "TAB/BACK",
"with no rebind the rows mirror the fixed map")
"with no rebind the rows mirror the fixed map, key and pad both (#589)")
check(cbGame.save.options.bindings == nil,
"opening the screen alone writes nothing")
check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil,
@@ -412,18 +412,20 @@ check(bm.capture == bm.items[1] and bm.onKeyPressed ~= nil,
local wroteOptions = false
function cbGame:writeOptions() wroteOptions = true end
bm:onKeyPressed("j")
bm:onKeyReleased("j") -- a capture commits on the press's release (#589)
check(cbGame.save.options.bindings.up.key == "j",
"a captured key lands in options.bindings")
check(bm.items[1].right == "J", "the row shows the new key")
check(bm.items[1].right == "J/D-UP", "the row shows the new key")
check(wroteOptions, "a rebind persists through writeOptions")
check(bm.capture == nil and bm.onKeyPressed == nil
and bm.onGamepadPressed == nil, "the capture disarms after one input")
bm.index = 5
press(bm, "a")
bm:onGamepadPressed("y")
bm:onGamepadReleased("y")
check(cbGame.save.options.bindings.a.pad == "y",
"a captured pad button lands beside the key slot")
check(bm.items[5].right == "Z", "a pad rebind keeps the key column")
check(bm.items[5].right == "Z/Y", "a pad rebind keeps the key column")
press(bm, "b")
check(#cbGame.stack.states == 0, "B closes the rebind screen")
+1 -1
View File
@@ -291,7 +291,7 @@ do
check(data.field.palettes.byMap.PALLET_TOWN == "PALLET",
"seed fills the missing siblings of a stamped record")
check(data.constants.world.stepFrames == 4, "seed leaves a stamped constant")
check(data.constants.world.turnFrames == 2, "seed fills the missing siblings")
check(data.constants.world.turnFrames == 4, "seed fills the missing siblings")
check(data.field.fishing.OLD_ROD.always.species == "MAGIKARP",
"seed installs the whole missing key")
local before = data.field.fishing

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