Compare commits

...

4 Commits

Author SHA1 Message Date
bryanthaboi 18b2bcd0a7 Merge pull request #873 from bryanthaboi/dev
bug fixes
2026-08-05 14:53:57 -04:00
bryanthaboi f56970350a Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
# Conflicts:
#	data/scripts/story4.lua
#	src/ui/DexEntryMenu.lua
#	tests/drivers/fighting_dojo_bug197_test.lua
2026-08-05 14:43:05 -04:00
bryanthaboi 863f371e68 CLOSES #806, CLOSES #809, CLOSES #853, CLOSES #854, CLOSES #860, CLOSES #862, CLOSES #865, CLOSES #866 2026-08-05 14:38:10 -04:00
github-actions 7def560726 chore(ios): update app-repo.json [skip ci] 2026-08-05 12:08:35 -04:00
24 changed files with 1694 additions and 51 deletions
+6 -2
View File
@@ -16,9 +16,13 @@ local function push(game, s, done)
game.stack:push(TextBox.new(game, s, done))
end
-- PrintText on a text_end string returns with the box still drawn and
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
-- engine/menus/text_box.asm); no A press clears the question first. Ride
-- TextBox's opts.choice, the same as Commands.ask (#854).
local function ask(game, s, cb)
local ChoiceBox = require("src.ui.ChoiceBox")
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
end
M.PEWTER_CITY = {
+6 -2
View File
@@ -23,9 +23,13 @@ local function push(game, s, done)
game.stack:push(TextBox.new(game, s, done))
end
-- PrintText on a text_end string returns with the box still drawn and
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
-- engine/menus/text_box.asm); no A press clears the question first. Ride
-- TextBox's opts.choice, the same as Commands.ask (#854).
local function ask(game, s, cb)
local ChoiceBox = require("src.ui.ChoiceBox")
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
end
M.VIRIDIAN_CITY = {
+45 -12
View File
@@ -517,6 +517,14 @@ M.GAME_CORNER = {
done()
return
end
-- GameCornerRocketText hands the battle its own loss line through
-- SaveEndBattleTextPointers (.BattleEndText ->
-- _GameCornerRocketBattleEndText, "Dang!"), and PrintEndBattleText
-- prints it ON the battle screen between TrainerDefeatedText and
-- MoneyForWinningText (engine/battle/core.asm TrainerBattleVictory).
-- He is a text_asm trainer with no def_trainers header, so there is no
-- header.won for engageTrainer to find and the line has to be handed
-- over here or it never shows at all (#862).
ow:engageTrainer(npc, function()
if not ow:trainerDefeated(npc) then
done()
@@ -527,19 +535,44 @@ M.GAME_CORNER = {
game.data.text._GameCornerRocketAfterBattleText
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
function()
-- #198: GameCornerRocketExitScript (scripts/GameCorner.asm)
-- ApplyMovementData walks the grunt one tile UP into the poster
-- (the hideout's secret entrance at 9,4) before HideObject, so
-- he leaves the floor rather than popping out of existence on
-- (9,5). scriptMove locks player input (#scriptMoves>0) and
-- ignores collision, so we despawn + unfreeze (done) only once
-- the step lands.
ow:scriptMove(npc, "up", 1, function()
hideRocket()
done()
end)
-- #198/#862: GameCornerRocketBattleScript (scripts/GameCorner.asm)
-- picks the exit walk from where the player is standing, because
-- the grunt on (9,5) has to get past him: wYCoord == 6 (talked to
-- from the south) or wXCoord == 8 (from the west) leaves the row
-- clear and takes GameCornerMovement_Rocket_WalkDirect, five steps
-- RIGHT; otherwise the player is east of him on (10,5) and
-- GameCornerMovement_Rocket_WalkAroundPlayer steps DOWN, right, UP
-- and right again to go AROUND him. pokeyellow's copy of the
-- around-path takes one extra RIGHT on the lower row before coming
-- back up (it also has to clear Pikachu); both versions end on
-- (15,5). He never steps UP: (9,4) is the poster wall, which is
-- where the old single UP step sent him.
local px = ow.player and ow.player.cellX
local py = ow.player and ow.player.cellY
local path
if py == 6 or px == 8 then
path = { { "right", 5 } }
elseif require("src.core.GameVersion").isYellow() then
path = { { "down", 1 }, { "right", 3 }, { "up", 1 }, { "right", 3 } }
else
path = { { "down", 1 }, { "right", 2 }, { "up", 1 }, { "right", 4 } }
end
-- GameCornerRocketExitScript only HideObjects him once
-- BIT_SCRIPTED_NPC_MOVEMENT clears, i.e. after the last step.
-- scriptMove locks player input (#scriptMoves>0) and ignores
-- collision, so the despawn + unfreeze (done) ride the final step.
local function step(i)
if i > #path then
hideRocket()
done()
return
end
ow:scriptMove(npc, path[i][1], path[i][2],
function() step(i + 1) end)
end
step(1)
end))
end)
end, game.data.text._GameCornerRocketBattleEndText or "Dang!")
end,
-- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a
-- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the
+11 -2
View File
@@ -13,9 +13,18 @@ local function push(game, s, done)
game.stack:push(TextBox.new(game, s, done))
end
-- The question stays on screen under the YES/NO menu. The dojo prize
-- balls are the clearest case: FightingDojoHitmonleePokeBallText
-- (scripts/FightingDojo.asm) is `call PrintText` on a text_end string --
-- no prompt, so no WaitForTextScrollButtonPress -- immediately followed
-- by `call YesNoChoice`, and InitYesNoTextBoxParameters
-- (engine/menus/text_box.asm) puts the menu above the dialogue box
-- rather than replacing it. Ride TextBox's opts.choice, the same as
-- Commands.ask, instead of popping the box with an A press and leaving a
-- bare ChoiceBox over the overworld (#854).
local function ask(game, s, cb)
local ChoiceBox = require("src.ui.ChoiceBox")
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
end
-- fill the extracted text placeholders ({NUM:...}, {RAM:...}, {PLAYER})
+6 -2
View File
@@ -12,9 +12,13 @@ local function push(game, s, done)
game.stack:push(TextBox.new(game, s, done))
end
-- PrintText on a text_end string returns with the box still drawn and
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
-- engine/menus/text_box.asm); no A press clears the question first. Ride
-- TextBox's opts.choice, the same as Commands.ask (#854).
local function ask(game, s, cb)
local ChoiceBox = require("src.ui.ChoiceBox")
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
end
-- -------------------------------------------------------------------
+45 -12
View File
@@ -62,10 +62,15 @@ M.MT_MOON_B2F = {
{ "walk_npc", 6, { "left", "left", "left", "left", "left" } },
{ "face_object", 6, "left" },
{ "show_text", "_MtMoonJessieJamesText2" },
-- MtMoonB2FScript12 arms _MtMoonJessieJamesText3 with
-- SaveEndBattleTextPointers before it sets wCurOpponent, so
-- TrainerBattleVictory prints it on the battle screen as "ROCKET: A
-- brat beat us?" between TrainerDefeatedText and MoneyForWinningText.
-- Its one-word first line only reads right behind that tag (#866).
{ "save_end_battle_text", "_MtMoonJessieJamesText3" },
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
{ "check_battle_result", "win" },
{ "jump_if_false", "end" },
{ "show_text", "_MtMoonJessieJamesText3" },
{ "show_text", "_MtMoonJessieJamesText4" },
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
@@ -85,7 +90,8 @@ M.MT_MOON_B2F = {
-- motto plays from off-screen FIRST, then the duo pops in at (25,10) /
-- (24,10) and whichever of them shares the player's column ($18=24 or
-- $19=25, EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT) walks the three
-- tiles down to loom over the player while the other steps one. A loss
-- tiles down to loom over the player while the other walks four and ends
-- up beside him. A loss
-- re-hides them (RocketHideoutB4FResetScripts via EVENT_6A0), so the
-- trigger re-arms clean.
-- -------------------------------------------------------------------
@@ -106,7 +112,7 @@ M.ROCKET_HIDEOUT_B4F = {
if f.EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES then return false end
-- ON_LEFT: player under James's column (25); movement data pairs
-- RocketHideoutB4FJessieJamesMovementData_45605/45606 swap so the
-- column-mate walks 3, the other 1.
-- column-mate walks 3, the other 4.
local onLeft = (x == 25)
ow.runner:run({
{ "stop_music" },
@@ -116,16 +122,30 @@ M.ROCKET_HIDEOUT_B4F = {
{ "emote", "player", "shock", 30 },
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
-- James (object 2) then Jessie (object 3), Script4..Script9 order
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
-- James (object 2) then Jessie (object 3), Script4..Script9 order.
-- RocketHideoutB4FJessieJamesMovementData_45605 is a lone $4 that FALLS
-- THROUGH into _45606 ($4 $4 $4 $ff), so MoveSprite_ (home/pathfinding.asm)
-- reads _45605 as FOUR steps and _45606 as three; $4 is DOWN in Yellow's
-- Func_5288 lookup (engine/overworld/movement.asm), which walks with no
-- collision test. From (25,10)/(24,10) against a player on y=14 the
-- column-mate stops three down, right above him, and the other walks the
-- full four to stand alongside -- which is what the facings below assume.
-- Reading _45605 as a single step stranded whoever was off-column three
-- tiles away, so James never reached the player (#865).
{ "walk_npc", 2, onLeft and { "down", "down", "down" }
or { "down", "down", "down", "down" } },
{ "face_object", 2, onLeft and "down" or "left" },
{ "walk_npc", 3, onLeft and { "down" } or { "down", "down", "down" } },
{ "walk_npc", 3, onLeft and { "down", "down", "down", "down" }
or { "down", "down", "down" } },
{ "face_object", 3, onLeft and "right" or "down" },
{ "show_text", "_RocketHideoutJessieJamesText2" },
-- RocketHideoutB4FScript10 saves _RocketHideoutJessieJamesText3 as the
-- end-battle text, so it prints as "ROCKET: Such a dreadful twerp!" on
-- the battle screen ahead of MoneyForWinningText (#866).
{ "save_end_battle_text", "_RocketHideoutJessieJamesText3" },
{ "start_battle", "trainer", "OPP_ROCKET", 43 },
{ "check_battle_result", "win" },
{ "jump_if_false", "lost" },
{ "show_text", "_RocketHideoutJessieJamesText3" },
{ "show_text", "_RocketHideoutJessieJamesText4" },
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
@@ -175,16 +195,27 @@ M.POKEMON_TOWER_7F = {
{ "show_text", "_PokemonTowerJessieJamesText1" },
{ "face_player_dir", "up" },
{ "emote", "player", "shock", 30 },
-- Jessie (object 1) then James (object 2), Script1..Script6 order
{ "walk_npc", 1, onLeft and { "down" } or { "down", "down", "down" } },
-- Jessie (object 1) then James (object 2), Script1..Script6 order.
-- Same fall-through blob as the hideout: PokemonTower7FMovementData_60d7a
-- is a lone $4 running into _60d7b ($4 $4 $4 $FF), so _60d7a is FOUR
-- steps and _60d7b is three. From (10,8)/(11,8) against a player on
-- y=12 the column-mate halts one tile above him and the other closes the
-- full four to his side; the single-step reading is why James only
-- "moved a bit" here (#865).
{ "walk_npc", 1, onLeft and { "down", "down", "down", "down" }
or { "down", "down", "down" } },
{ "face_object", 1, onLeft and "right" or "down" },
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
{ "walk_npc", 2, onLeft and { "down", "down", "down" }
or { "down", "down", "down", "down" } },
{ "face_object", 2, onLeft and "down" or "left" },
{ "show_text", "_PokemonTowerJessieJamesText2" },
-- PokemonTower7FScript7 saves _PokemonTowerJessieJamesText3 as the
-- end-battle text: "ROCKET: You will regret this!" on the battle screen,
-- before the prize money (#866).
{ "save_end_battle_text", "_PokemonTowerJessieJamesText3" },
{ "start_battle", "trainer", "OPP_ROCKET", 44 },
{ "check_battle_result", "win" },
{ "jump_if_false", "end" },
{ "show_text", "_PokemonTowerJessieJamesText3" },
{ "show_text", "_PokemonTowerJessieJamesText4" },
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
@@ -254,10 +285,12 @@ M.SILPH_CO_11F = {
{ "walk_npc", 6, jessieDirs },
{ "face_object", 6, jessieFace },
{ "show_text", "_SilphCoJessieJamesText2" },
-- SilphCo11FScript11 saves _SilphCoJessieJamesText3 (SilphCo11FText_624c2)
-- as the end-battle text: "ROCKET: Like always..." before the money (#866).
{ "save_end_battle_text", "_SilphCoJessieJamesText3" },
{ "start_battle", "trainer", "OPP_ROCKET", 45 },
{ "check_battle_result", "win" },
{ "jump_if_false", "end" },
{ "show_text", "_SilphCoJessieJamesText3" },
{ "show_text", "_SilphCoJessieJamesText4" },
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
+25
View File
@@ -332,6 +332,26 @@ one used sideways. An `options.lua` from before this split keeps its single
layout in both orientations until one of them is edited. In-game, Options →
**TOUCH PAD** toggles the same on/off flag without leaving a play session.
## Haptic feedback (mobile)
Options → **VIBRATION** (also in the launcher's gear menu) buzzes the device
the instant an on-screen control takes a button (#806). A glass pad has no
edges under a thumb, so the pulse is what tells you the press landed:
sliding the d-pad from one direction to the next buzzes again, a second
finger landing on a button that is already held does not, and releasing
never does.
Four levels: **OFF**, **LIGHT** (the default), **MEDIUM**, **HEAVY**.
"Intensity" is really a pulse length -- the platform call takes a duration
and nothing else -- so LIGHT is a 12 ms tick, MEDIUM 25 ms, HEAVY 45 ms.
Stepping the row fires one sample pulse at the level you land on, so the
three can be compared without leaving the menu. On iOS the system
vibration has one fixed length, so all three levels feel the same there and
the row is effectively on/off. The setting lives in `options.lua` and the
row only appears where the on-screen pad can (Android/iOS, or desktop with
`POKEPORT_TOUCH=1`, where it does nothing since desktop LOVE has no
vibrator).
## Screen orientation lock (Android)
Options → **ORIENTATION** (also in the launcher's gear menu) locks the
@@ -585,6 +605,11 @@ settings gear and pulses when an update is waiting, instead of sitting in a
banner at the bottom of the page that you had to scroll to notice. Checking
for updates from there shows a loader like everything else.
**A quit button.** An X sits to the right of the settings gear and closes
the app cleanly, the same shutdown path as the window's close button. Mostly
for platforms where reaching the window chrome is awkward (Android, Steam
Deck, fullscreen desktops).
**The look.** Black background, white outlines, no gradients or glows, and
buttons that are solid colour-coded keys: green commits, blue navigates, red
destroys, yellow wants attention. The three game tabs keep their red, blue
+7
View File
@@ -12,6 +12,13 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.1.70",
"date": "2026-08-05",
"size": 9562731,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #590 Launcher/Save Editor/Dex\n- #788 Fly to the Pokemon Centers in the Routes is not available in original\n- #795 Fly logic is drastically different from the originals.\n- #796 Using Rare Candy from the menu closes it out.\n- #797 Gym Leaders giving items when your bag is full / Bypassing bag limit.\n- #805 Escape Rope Moltres Tower\n- #826 \"Super effective\" and \"not very effective\" SFX are reversed\n- #833 Cancelling nickname entry results in \"A\" as the nickname\n- #835 Restarting the launcher forgets the last rom used\n- #837 Wrong sound effect for Pikachu when entering battle\n- #844 Blizzard sound effect.\n- #845 Moderate issue: Fuchsia City binoculars.\n- #846 Surfing speed after using the bicycle.\n- #847 Minor issues related to the endgame.\n\n## Contributors\n\n- @bryanthaboi\n- @dburton95\n- @johnjohto\n- @KikiManjaro"
},
{
"version": "0.1.69",
"date": "2026-08-04",
+15 -5
View File
@@ -3188,6 +3188,16 @@ function BattleState:executeAction(user, target, action)
user.boundTurns = target.trappingTurns
and math.max(1, target.trappingTurns) or nil
-- wPlayerSelectedMove / wEnemySelectedMove as the status gauntlet
-- reads it: the locked specials keep continuing the move they
-- started, so they carry a move id too. Resolved once here and
-- handed to every statusInterrupt below, which is where
-- .TriedToUseDisabledMoveCheck lives (#860).
local selectedId = action.id
or (action.special == "trapping" and user.trapMove)
or (action.special == "bide" and "BIDE")
or nil
-- trainer class AI actions (engine/battle/trainer_ai.asm)
if action.special == "aiItem" then
self.aiUses = (self.aiUses or 1) - 1
@@ -3245,17 +3255,17 @@ function BattleState:executeAction(user, target, action)
return
end
if action.special == "trapping" then
if self:statusInterrupt(user, target) then return end
if self:statusInterrupt(user, target, selectedId) then return end
self:continueTrapping(user, target)
return
end
if action.special == "bide" then
if self:statusInterrupt(user, target) then return end
if self:statusInterrupt(user, target, selectedId) then return end
self:continueBide(user, target)
return
end
if self:statusInterrupt(user, target) then return end
if self:statusInterrupt(user, target, selectedId) then return end
self:performMove(user, target, action, false)
end
run()
@@ -3349,8 +3359,8 @@ end
-- Runs Status.beforeMove plus the shared interruption bookkeeping;
-- returns true when the user's action is interrupted.
function BattleState:statusInterrupt(user, target)
local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self)
function BattleState:statusInterrupt(user, target, selectedId)
local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self, selectedId)
for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end
if selfHit then
-- confusion self-hit (core.asm:3428-3434): clears everything in
+23 -1
View File
@@ -168,7 +168,7 @@ end
-- The active status record's beforeMove runs at its priority slot: above
-- VOLATILE_PRIORITY before the held/disable/confusion block (sleep,
-- freeze), at or below after it (paralysis) -- the original's order.
function Status.beforeMove(battler, rng, battle)
function Status.beforeMove(battler, rng, battle, selectedMoveId)
local mon = battler.mon
-- Haze curing this mon's sleep/freeze forfeits its pending move for
-- the turn, silently (haze.asm writes $ff/CANNOT_MOVE to the selected
@@ -225,6 +225,28 @@ function Status.beforeMove(battler, rng, battle)
end
end
end
-- .TriedToUseDisabledMoveCheck (engine/battle/core.asm, and the enemy
-- copy .checkIfTriedToUseDisabledMove): the disabled-move test runs at
-- EXECUTION time, comparing wPlayerDisabledMoveNumber against the
-- already SELECTED move, so a Disable that lands earlier in the same
-- turn still blocks the slower mon's move (#860). It sits after the
-- confusion block and before the paralysis roll, so a confusion self-hit
-- still pre-empts it and the paralysis roll is never spent on a turn the
-- disable eats. PrintMoveIsDisabledText clears CHARGING_UP before
-- printing, so a disabled charge move drops its stored turn instead of
-- releasing later.
if selectedMoveId and battler.disabledSlot then
local disabled = (battler.curMoves or {})[battler.disabledSlot]
if disabled and disabled.id == selectedMoveId then
battler.charging, battler.chargeReady = nil, nil
local moves = battle and battle.data and battle.data.moves
local shown = moves and moves[selectedMoveId] and moves[selectedMoveId].name
or tostring(selectedMoveId)
table.insert(msgs, romText(battle and battle.data, "_MoveIsDisabledText",
"%s's\n%s is\ndisabled!", name(battler), shown))
return false, msgs
end
end
if handler then
local canMove, selfHit = runStatus()
if not canMove or selfHit then return canMove, msgs, selfHit end
+53 -1
View File
@@ -30,6 +30,15 @@ local SaveData = {}
-- deliberately shared across versions (it holds global preferences and the
-- mod enable-state, not per-playthrough data).
local OPTIONS_FILENAME = "options.lua"
-- #828: options.lua is rewritten whole on every write (see saveOptions), and
-- unlike the progress files it had no staged copy, so a write interrupted
-- between the truncate and the flush -- the process replaced by
-- HostShell.restart on the way back to the launcher, an Android
-- external-storage volume that never flushed -- left a truncated or empty
-- file that loadOptions could only answer with defaults: every setting
-- "reset" at once. Same .bak/.tmp witness names the save files use.
local OPTIONS_BACKUP_FILENAME = OPTIONS_FILENAME .. ".bak"
local OPTIONS_TMP_FILENAME = OPTIONS_FILENAME .. ".tmp"
-- Main / backup / staged-witness names for a version (defaults to the active
-- one). The backup is a rolling copy and .tmp is the staged-write witness;
@@ -305,6 +314,13 @@ function SaveData.defaultOptions()
-- layout (#633). Pre-#633 files stored one top-level positions table;
-- TouchControls.normalizeConfig folds it into both orientations on load.
touchControls = { enabled = true },
-- Haptic feedback level for on-screen pad presses (#806):
-- off | light | medium | heavy, mapped to a love.system.vibrate
-- duration in src/core/TouchControls.lua. LIGHT by default, like the
-- overlay itself defaulting on, so an options.lua predating this key
-- gets the tick without going looking for the row. Inert wherever the
-- overlay never appears (desktop) or LOVE has no vibrator.
haptics = "light",
}
end
@@ -369,7 +385,21 @@ function SaveData.saveOptions(opts, fs)
opts.modOptions = merged
end
local encoded = SaveSerializer.encode(opts)
local ok, err = fs.write(OPTIONS_FILENAME, encoded)
-- Stage the new bytes and roll the last good file aside BEFORE the main
-- write truncates it, the same tmp/bak dance SaveData.save uses for
-- progress: whatever ends the process mid-write, one of the three copies
-- is complete and loadOptions promotes it instead of falling back to
-- defaults (#828).
local ok, err = fs.write(OPTIONS_TMP_FILENAME, encoded)
if not ok then
Logger.error("options save failed: %s", tostring(err))
return nil
end
local prev = fs.getInfo(OPTIONS_FILENAME) and fs.read(OPTIONS_FILENAME)
if type(prev) == "string" and prev ~= "" and prev ~= encoded then
fs.write(OPTIONS_BACKUP_FILENAME, prev)
end
ok, err = fs.write(OPTIONS_FILENAME, encoded)
if not ok then
Logger.error("options save failed: %s", tostring(err))
return nil
@@ -387,6 +417,8 @@ function SaveData.saveOptions(opts, fs)
#encoded, type(wrote) == "string" and tostring(#wrote) or "nothing")
return nil
end
-- the staged witness has served its purpose; the main file is verified
remove(fs, OPTIONS_TMP_FILENAME)
return opts
end
@@ -397,6 +429,26 @@ function SaveData.loadOptions(fs)
if fs.getInfo(OPTIONS_FILENAME) then
Logger.error("options load failed: %s", tostring(err))
end
-- #828: answering defaults here is what "closing the game reset all my
-- settings" looked like -- one interrupted whole-file rewrite and every
-- preference, the mod enable-state and the slot registry were gone.
-- Promote the staged copy, then the rolled-aside backup, exactly as
-- SaveData.load does for progress, and heal the main file from whichever
-- one parsed.
local recovered = readTable(fs, OPTIONS_TMP_FILENAME)
local from = "tmp"
if not recovered then
recovered = readTable(fs, OPTIONS_BACKUP_FILENAME)
from = "bak"
end
if recovered then
Logger.warn("options.lua %s; recovered from %s copy",
fs.getInfo(OPTIONS_FILENAME) and "corrupt" or "missing", from)
if fs.write then
fs.write(OPTIONS_FILENAME, SaveSerializer.encode(recovered))
end
return SaveData.mergeOptions(recovered)
end
return SaveData.defaultOptions()
end
return SaveData.mergeOptions(data)
+64 -1
View File
@@ -85,6 +85,55 @@ local function clampScale(v)
return v
end
-- Haptic feedback (#806): a short vibration the instant a control takes a GB
-- button, the way every mobile emulator front-end does it -- the pad has no
-- edges under a thumb, so the buzz is the only confirmation a press landed.
-- Persisted as options.haptics (src/core/SaveData.lua defaultOptions), NOT
-- under options.touchControls: TouchControls:config() is the launcher
-- editor's save snapshot and only emits enabled + layouts, so a nested key
-- would be dropped on every editor save.
-- love.system.vibrate takes a duration and nothing else, so "intensity" is a
-- duration preset: Android runs the platform vibrator for exactly that long,
-- while iOS ignores the duration and fires the fixed system vibration, so
-- there the three levels all read as simply on.
TouchControls.HAPTICS = { "off", "light", "medium", "heavy" }
TouchControls.HAPTIC_DEFAULT = "light"
local HAPTIC_SECONDS = { off = 0, light = 0.012, medium = 0.025, heavy = 0.045 }
local HAPTIC_LABELS = {
off = "OFF", light = "LIGHT", medium = "MEDIUM", heavy = "HEAVY",
}
function TouchControls.normalizeHaptics(level)
if HAPTIC_SECONDS[level] then return level end
return TouchControls.HAPTIC_DEFAULT
end
function TouchControls.hapticLabel(level)
return HAPTIC_LABELS[TouchControls.normalizeHaptics(level)]
end
function TouchControls.cycleHaptics(level, dir)
local cur, idx = TouchControls.normalizeHaptics(level), 1
for i, m in ipairs(TouchControls.HAPTICS) do
if m == cur then idx = i break end
end
local n = #TouchControls.HAPTICS
return TouchControls.HAPTICS[(idx - 1 + (dir or 1)) % n + 1]
end
-- One pulse at the given level. Feature-guarded rather than platform-gated:
-- love.system.vibrate is a no-op on desktop and absent from the headless love
-- stubs, so the press path below stays identical everywhere and the tests
-- never reach a vibrator.
function TouchControls.buzz(level)
local secs = HAPTIC_SECONDS[TouchControls.normalizeHaptics(level)]
if not secs or secs <= 0 then return false end
if not (love and love.system and love.system.vibrate) then return false end
pcall(love.system.vibrate, secs)
return true
end
-- Copy a persisted positions table, dropping unknown / non-numeric entries.
-- Always a fresh table: two orientations seeded from the same pre-#633
-- layout must not alias, or dragging one would still move the other.
@@ -174,6 +223,10 @@ end
function TouchControls:init()
self.active = wantsOverlay()
self.enabled = true
-- vibration level for presses (#806); applyOptions overwrites it from
-- options.haptics, this is the value a harness that never applies options
-- runs with
self.haptics = TouchControls.HAPTIC_DEFAULT
-- per-orientation buckets (#633); self.positions / self.scale mirror the
-- one currently on screen so layout(), the editor and the tests keep a
-- single lookup
@@ -210,6 +263,9 @@ end
function TouchControls:applyOptions(opts)
local cfg = TouchControls.normalizeConfig(opts and opts.touchControls)
self.enabled = cfg.enabled
-- haptics is a plain top-level option, not part of the layout config the
-- launcher editor round-trips through config() (#806)
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
self.layouts = cfg.layouts
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
@@ -401,7 +457,14 @@ end
local function pressBtn(self, btn)
local n = (self.held[btn] or 0) + 1
self.held[btn] = n
if n == 1 then Input:overlayPressed(btn) end
-- Buzz only on the 0 -> 1 edge, the same edge that presses the GB button:
-- a second finger landing on a button that is already held, and a d-pad
-- finger resting inside one direction, must not retrigger it. Sliding the
-- d-pad to a new direction does, which is the point (#806).
if n == 1 then
Input:overlayPressed(btn)
TouchControls.buzz(self.haptics)
end
end
local function releaseBtn(self, btn)
+12
View File
@@ -242,6 +242,18 @@ local function coreRows(opts)
opts.touchControls = tc
return true
end)
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
-- the row buzzes once at the level being selected.
local okTC, TC = pcall(require, "src.core.TouchControls")
if okTC then
add(Strings("VIBRATION"),
function() return Strings(TC.hapticLabel(opts.haptics)) end,
function(dir)
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
TC.buzz(opts.haptics)
return true
end)
end
end
end
+38 -2
View File
@@ -311,8 +311,20 @@ local function setPage(imp, key, v)
imp._pages[key] = v
end
-- A hand-drawn X, for the same reason drawCheck exists below: the UI font has
-- no guaranteed glyph, and the launcher ships no icon asset for it.
local function drawCross(x, y, size, color)
love.graphics.push("all")
love.graphics.setColor(color)
love.graphics.setLineWidth(math.max(2, size * 0.16))
love.graphics.setLineJoin("bevel")
love.graphics.line(x, y, x + size, y + size)
love.graphics.line(x + size, y, x, y + size)
love.graphics.pop()
end
-- ------------------------------------------------------------- header
-- Rail, logo row (settings on the right), tab bar.
-- Rail, logo row (settings and quit on the right), tab bar.
-- Returns the y at which content may start. Its vertical arithmetic is
-- mirrored by headerHeight() at the bottom of this file (the short-window
-- scroll decision needs the height before anything draws) -- keep in sync.
@@ -340,7 +352,14 @@ local function buildHeader(imp, m)
local rx = m.x + m.w - m.pad
local by = y + (rowH - gear) / 2
-- Settings gear, top-right corner.
-- The right cluster is laid out right to left -- Quit outermost, the gear
-- inboard of it -- but the two are REGISTERED gear first, because the first
-- focusable of the first frame adopts the keyboard ring and that must not be
-- the button that exits the app.
local quitX = rx - gear
rx = quitX - math.floor(6 * m.s)
-- Settings gear.
imp._gearIcon = imp._gearIcon
or love.graphics.newImage("assets/launcher/gear.png")
rx = rx - gear
@@ -365,6 +384,23 @@ local function buildHeader(imp, m)
end
end
-- Quit, top-right corner.
do
local x = quitX
Kit._audit("control", x, by, gear, gear, "quit")
local focused = Kit.focusable("quit", x, by, gear, gear)
local hot = focused or Kit.hover(x, by, gear, gear)
Theme.fill(x, by, gear, gear, hot and PAL.ink or PAL.bg, 1)
Theme.stroke(x, by, gear, gear, PAL.line,
hot and Theme.A.focus or Theme.A.hairline, 1)
local pad = math.floor(gear * 0.32)
drawCross(x + pad, by + pad, gear - 2 * pad,
hot and { 0, 0, 0, 1 } or { 1, 1, 1, 0.85 })
if Kit.press(x, by, gear, gear) or Kit._activateId == "quit" then
queueAction(imp, "quit", function() imp:_quitApp() end)
end
end
-- The self-update control lives in the FOOTER next to the BCG mark (small,
-- out of the wordmark's way -- it used to overlap the logo on a phone). It
-- still GLOWS through Kit.button when there is something to act on.
+7
View File
@@ -2459,6 +2459,13 @@ function RomImporter:_openSettings()
if ok and model then self._settings = model end
end
-- Quit from the launcher's own X. It goes through love.event.quit so main.lua's
-- love.quit hook still runs: that is where the worker threads are shut down
-- (#339) and where a launcher close is told apart from a running game's (#785).
function RomImporter:_quitApp()
if love.event and love.event.quit then love.event.quit() end
end
function RomImporter:_closeSettings()
if self._settings then self._settings.save() end
self._settings = nil
+23
View File
@@ -260,6 +260,26 @@ function Commands.take_item(ctx, itemId, count)
if inv[itemId] == 0 then inv[itemId] = nil end
end
-- save_end_battle_text <TEXT_KEY>: SaveEndBattleTextPointers
-- (home/trainers.asm, called from e.g. RocketHideoutB4FScript10 just before
-- wCurOpponent is set). The armed line is the trainer's OWN loss line and
-- belongs on the battle screen: PrintEndBattleText (home/trainers.asm) runs
-- from TrainerBattleVictory (engine/battle/core.asm) after
-- TrainerDefeatedText and the pic scroll but BEFORE MoneyForWinningText, and
-- TrainerEndBattleText prints _TrainerNameText first so the line opens with
-- the "CLASS: " tag. Scripts that printed it with a plain show_text after
-- start_battle got it a box too late -- after the payout -- and untagged
-- (#866). Arms exactly one battle; start_battle consumes it.
function Commands.save_end_battle_text(ctx, textId)
local text = ctx.game.data.text[textId]
if not text and ctx.overworld then
text = ctx.game.data:resolveText(ctx.overworld.map.def.label, textId)
end
-- BattleState takes finished text, so expand {PLAYER}/{RIVAL} here the
-- way OverworldState:engageTrainer does for the sight/talk path
ctx.endBattleText = TextBox.substitute(ctx.game, text or textId)
end
-- start_battle "wild" species level | start_battle "trainer" OPP_CLASS partyIndex
function Commands.start_battle(ctx, kind, a, b)
local BattleState = require("src.battle.BattleState")
@@ -270,6 +290,9 @@ function Commands.start_battle(ctx, kind, a, b)
else
battle = BattleState.newTrainer(ctx.game, a, b)
end
-- one SaveEndBattleTextPointers arms one battle; leaving it set would leak
-- the line into the next scripted fight
battle.endBattleText, ctx.endBattleText = ctx.endBattleText, nil
battle.onFinish = function(result)
ctx.lastBattleResult = result
ctx.lastCheck = result == "win"
+26 -3
View File
@@ -437,6 +437,25 @@ local function buildRows(game)
require("src.core.TouchControls"):applyOptions(o)
return true
end },
-- Haptic feedback for on-screen pad presses (#806): OFF / LIGHT /
-- MEDIUM / HEAVY, where the intensity is a vibration duration --
-- love.system.vibrate takes nothing else. Hidden with TOUCH PAD below,
-- since the only thing that buzzes is a virtual button press.
{ id = "haptics", label = Strings("VIBRATION"),
value = function(g)
local TC = require("src.core.TouchControls")
return Strings(TC.hapticLabel(g.save.options.haptics))
end,
step = function(g, dir)
local o = g.save.options
local TC = require("src.core.TouchControls")
o.haptics = TC.cycleHaptics(o.haptics, dir)
TC:applyOptions(o)
-- sample the level being selected: stepping the row is the only way
-- to compare LIGHT against HEAVY without leaving the menu
TC.buzz(o.haptics)
return true
end },
}
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
if not GBCFX.isSupported() then
@@ -454,8 +473,10 @@ local function buildRows(game)
end
rows = filtered
end
-- TOUCH PAD only where the overlay can appear (mobile, or desktop with
-- POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off everywhere.
-- TOUCH PAD and VIBRATION only where the overlay can appear (mobile, or
-- desktop with POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off
-- everywhere. VIBRATION rides the same gate: nothing else in the port
-- vibrates, and love.system.vibrate is a no-op on desktop anyway.
do
local env = os.getenv("POKEPORT_TOUCH")
local osName = love.system and love.system.getOS and love.system.getOS()
@@ -464,7 +485,9 @@ local function buildRows(game)
if not show then
local filtered = {}
for _, row in ipairs(rows) do
if row.id ~= "touchControls" then filtered[#filtered + 1] = row end
if row.id ~= "touchControls" and row.id ~= "haptics" then
filtered[#filtered + 1] = row
end
end
rows = filtered
end
+54 -6
View File
@@ -1259,8 +1259,14 @@ end
function OverworldState:checkBoulderPush(dir)
local p = self.player
local fx, fy = Collision.target(p.cellX, p.cellY, dir)
local npc = self:npcAtCell(fx, fy)
if not npc or not Map.isPushable(npc.def) or npc.moving then
-- IsSpriteInFrontOfPlayer (home/overworld.asm) hands TryPushingBoulder
-- the LOWEST sprite index standing on the faced cell, so in the original a
-- second sprite parked on the boulder's cell hides the boulder from the
-- push path for the rest of the map visit. Pick the pushable sprite out of
-- the cell instead: a scripted walk-up that lands a trainer on the boulder
-- must not brick it permanently (#809).
local npc = self:pushableAtCell(fx, fy)
if not npc or npc.moving then
self.boulderTried = nil -- pokered resets when no boulder is in front
return false
end
@@ -1720,6 +1726,22 @@ function OverworldState:npcAtCell(cx, cy)
return nil
end
-- The Strength boulder on a cell, ignoring anything else standing there.
-- npcAtCell returns whichever object the map listed first, which is only
-- well defined while at most one sprite occupies a cell; scripted walks
-- (TrainerWalkUpToPlayer) can break that, and the push path must still find
-- the boulder underneath (#809).
function OverworldState:pushableAtCell(cx, cy)
for _, npc in ipairs(self.npcs) do
if ((npc.cellX == cx and npc.cellY == cy) or
(npc.targetX == cx and npc.targetY == cy))
and Map.isPushable(npc.def) then
return npc
end
end
return nil
end
-- what the A press resolved to, for world.interacted's listeners
local function interacted(self, fx, fy, kind, target)
Runtime.emit("world.interacted", { mapId = self.map.id, x = fx, y = fy,
@@ -2962,7 +2984,7 @@ local function meetTrainerTheme(cls)
end
-- Run the pre-battle text -> battle -> won text -> flags sequence.
function OverworldState:engageTrainer(npc, onDone)
function OverworldState:engageTrainer(npc, onDone, endBattleText)
local d = npc.def
Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass,
partyIndex = d.trainerParty })
@@ -2972,7 +2994,15 @@ function OverworldState:engageTrainer(npc, onDone)
battleText = select(1, Game.data:resolveText(self.map.def.label, d.text))
or Strings("I like shorts!\nThey're comfy and\neasy to wear!")
end
local wonText = header and header.won and Game.data.text[header.won]
-- `endBattleText` is a caller-supplied stand-in for header.won: the
-- text_asm trainers that hand their loss line to the battle through
-- SaveEndBattleTextPointers (scripts/GameCorner.asm GameCornerRocketText
-- passes _GameCornerRocketBattleEndText, "Dang!") have no def_trainers
-- header for the extractor to read, so their script passes the finished
-- line here and it still lands where PrintEndBattleText puts it -- between
-- TrainerDefeatedText and MoneyForWinningText, on the battle screen (#862).
local wonText = endBattleText
or (header and header.won and Game.data.text[header.won])
local BattleState = require("src.battle.BattleState")
Game.stack:push(TextBox.new(Game, battleText, function()
@@ -3241,8 +3271,26 @@ function OverworldState:startTrainerApproach(npc, dist)
self.emote = {
npc = npc, frames = 60,
onDone = function()
if dist > 1 then
self:scriptMove(npc, npc.facing, dist - 1, fight)
-- TrainerWalkUpToPlayer (engine/overworld/trainer_sight.asm) writes
-- dist-1 NPC_MOVEMENT_* bytes and hands them to MoveSprite, and every
-- scripted step skips collision entirely (CanWalkOntoTile,
-- engine/overworld/movement.asm: "always allow walking if the
-- movement is scripted"), so the original marches the trainer straight
-- through a Strength boulder sitting on the sight line. Stop one cell
-- short of the boulder instead: two sprites on one cell is a state the
-- push path cannot represent, and the walk-up is the one scripted move
-- the player can steer a boulder into (#809).
local steps = dist - 1
local cx, cy = npc.cellX, npc.cellY
for i = 1, steps do
cx, cy = Collision.target(cx, cy, npc.facing)
if self:pushableAtCell(cx, cy) then
steps = i - 1
break
end
end
if steps > 0 then
self:scriptMove(npc, npc.facing, steps, fight)
else
fight()
end
@@ -0,0 +1,222 @@
-- A trainer's walk-up must stop short of a Strength boulder, and the boulder
-- must still be pushable afterwards (#809). TrainerWalkUpToPlayer (pokered
-- engine/overworld/trainer_sight.asm) writes dist-1 movement bytes that skip
-- collision, so the trainer used to park ON the boulder, and after that
-- IsSpriteInFrontOfPlayer (home/overworld.asm) handed TryPushingBoulder the
-- trainer instead of the rock. POKEPORT_DRIVER=tests/drivers/boulder_trainer_bug809_test.lua POKEPORT_IDENTITY=bug809 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
-- No POKEPORT_SPEED: the sighting, the "!" bubble and the walk-up all run at
-- the normal 60 Hz logic clock so the stop-short frame is the one a player
-- would see. The setup pushes are slow for the same reason; the run takes
-- about half a minute of real time before it hands the pad over.
return function(game)
local U = dofile("tests/drivers/util.lua")
-- pokered data/maps/objects/VictoryRoad3F.asm:
-- object_event 13, 3, SPRITE_COOLTRAINER_F, STAY, RIGHT, ..., OPP_COOLTRAINER_F, 3
-- object_event 22, 3, SPRITE_BOULDER, STAY, BOULDER_MOVEMENT_BYTE_2, ...
-- Her header range is 4 (data/generated/trainer_headers.lua VictoryRoad3F[4]),
-- so she spots the player anywhere on row 3 within four cells to her east and
-- then walks dist-1 cells toward him. Row 3 is walled at x=19, so BOULDER1
-- cannot simply be shoved west into her sight line: it has to go down column
-- 22 to row 6, west along row 6, and back up column 17 onto row 3.
local MAP = "VICTORY_ROAD_3F"
local MAP_LABEL = "VictoryRoad3F"
local BOULDER = "VICTORYROAD3F_BOULDER1"
local TRAINER = "VICTORYROAD3F_COOLTRAINER_F2"
local START = { x = 22, y = 2, facing = "down" }
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local pass = true
local function check(label, ok)
if not ok then pass = false end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function findNpc(ow, 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
-- Hold `btn` until `cond` goes true or the budget runs out, then release and
-- let any half-finished step land. Boulder pushes need a held direction:
-- handleInput only reaches checkBoulderPush while the player already faces
-- that way, and TryPushingBoulder arms on one poll and moves on the next
-- (BIT_TRIED_PUSH_BOULDER), so a single tap can never shift a rock.
local function holdUntil(btn, cond, budget)
local first = true
for _ = 1, budget or 600 do
if cond() then break end
if first then table.insert(game.input.pressQueue, btn); first = false end
game.input.state[btn] = true
coroutine.yield()
end
game.input.state[btn] = false
for _ = 1, 40 do
if not game.overworld.player.moving and #game.overworld.scriptMoves == 0 then
break
end
coroutine.yield()
end
U.wait(4) -- an input-free poll re-arms turning in place (wCheckFor180DegreeTurn)
return cond()
end
U.teleport(game, MAP, START.x, START.y, START.facing)
U.wait(10)
local ow = game.overworld
local rock = findNpc(ow, BOULDER)
local trainer = findNpc(ow, TRAINER)
check("BOULDER1 loaded on " .. MAP, rock ~= nil)
check("COOLTRAINER_F2 loaded on " .. MAP, trainer ~= nil)
if not (rock and trainer) then
U.log("map objects missing; nothing to drive")
while true do coroutine.yield() end
end
check("BOULDER1 starts at the asm cell (22,3)",
rock.cellX == 22 and rock.cellY == 3)
check("COOLTRAINER_F2 starts at the asm cell (13,3) facing right",
trainer.cellX == 13 and trainer.cellY == 3 and trainer.facing == "right")
check("checkBoulderPush resolves through pushableAtCell",
type(ow.pushableAtCell) == "function")
local header = game.data:trainerHeader(MAP_LABEL, trainer.def.index)
local range = header and header.range or 0
check("her sight range is 4 cells", range == 4)
-- The whole route, so a map or tileset edit shows up here instead of as a
-- driver that quietly wanders off. If a cell is not walkable the boulder
-- cannot be pushed onto it (CheckForCollisionWhenPushingBoulder reuses the
-- player's passability check) and the run is not worth continuing.
local ROUTE = {
{ 22, 4 }, { 22, 5 }, { 22, 6 }, { 23, 5 }, { 23, 6 },
{ 21, 6 }, { 20, 6 }, { 19, 6 }, { 18, 6 }, { 17, 6 },
{ 18, 7 }, { 17, 7 }, { 17, 5 }, { 17, 4 }, { 17, 3 },
{ 18, 4 }, { 18, 3 }, { 16, 3 }, { 16, 4 }, { 16, 2 },
}
local routeOk = true
for _, c in ipairs(ROUTE) do
if not ow.map:isWalkableCell(c[1], c[2]) then
routeOk = false
U.log("route cell not walkable:", c[1], c[2])
end
end
check("the push route is walkable end to end", routeOk)
-- STRENGTH is live for the map visit. BIT_STRENGTH_ACTIVE is what
-- TryPushingBoulder gates on -- it never re-reads badges or party moves --
-- so setting the field-move state is the whole grant (see the comment in
-- OverworldState:checkBoulderPush).
ow.strengthActive = true
-- Victory Road rolls a wild encounter on every completed step, not just in
-- grass (wild_encounters.asm counts caves as indoor), and this run walks
-- twenty-odd cells with an empty party. Drop the map's table: a wild
-- battle mid-route interrupts the push with a screen transition and has
-- nothing to do with what is being checked.
game.data.encounters[MAP] = nil
if not pass then
U.log("setup checks already failed; not driving the push")
while true do coroutine.yield() end
end
local function boulderAt(x, y)
return function() return rock.cellX == x and rock.cellY == y end
end
local function playerAt(x, y)
local p = ow.player
return function() return p.cellX == x and p.cellY == y end
end
-- down column 22 to row 6
holdUntil("down", boulderAt(22, 6), 400)
check("boulder pushed down column 22 to (22,6)", rock.cellX == 22 and rock.cellY == 6)
-- around to its east side
holdUntil("right", playerAt(23, 5), 120)
holdUntil("down", playerAt(23, 6), 120)
-- west along row 6 to the column that reaches row 3
holdUntil("left", boulderAt(17, 6), 700)
check("boulder pushed west along row 6 to (17,6)", rock.cellX == 17 and rock.cellY == 6)
-- around to its south side
holdUntil("down", playerAt(18, 7), 120)
holdUntil("left", playerAt(17, 7), 120)
-- up column 17 onto her row
holdUntil("up", boulderAt(17, 3), 400)
check("boulder pushed up column 17 onto row 3 at (17,3)",
rock.cellX == 17 and rock.cellY == 3)
if not pass then
U.log("the boulder never reached her row; the race below cannot happen")
while true do coroutine.yield() end
end
-- Step onto row 3 one cell out of range (18 - 13 = 5 > 4) so the sighting
-- happens on the push itself and not a moment earlier.
holdUntil("right", playerAt(18, 4), 120)
holdUntil("up", playerAt(18, 3), 120)
check("player waiting at (18,3), one cell outside her range",
ow.player.cellX == 18 and ow.player.cellY == 3 and not ow.engaging)
-- The engage lands on a battle we are not going to fight: stand in for it,
-- record where the walk-up stopped, and mark her beaten the way winning
-- would. Everything the walk-up does has already happened by this point.
local stopped
local realEngage = ow.engageTrainer
ow.engageTrainer = function(self, npc, onDone)
stopped = { npc = npc, x = npc.cellX, y = npc.cellY }
game.save.defeatedTrainers[npc.id] = true
if onDone then onDone() end
end
-- One push west: the boulder lands on (16,3) and the player follows onto
-- (17,3), four cells from her, which is the frame she spots him on.
holdUntil("left", function() return stopped ~= nil end, 400)
check("she spotted the player and finished her walk-up", stopped ~= nil)
check("the boulder moved one cell west to (16,3)",
rock.cellX == 16 and rock.cellY == 3)
if stopped then
U.log("she stopped at", stopped.x, stopped.y, "boulder at", rock.cellX, rock.cellY)
check("she is not standing on the boulder cell",
not (stopped.x == rock.cellX and stopped.y == rock.cellY))
check("she stopped one cell short of it, at (15,3)",
stopped.x == 15 and stopped.y == 3)
check("the push path still finds the boulder under that cell",
ow:pushableAtCell(rock.cellX, rock.cellY) == rock)
check("nothing else shares the boulder's cell",
ow:npcAtCell(rock.cellX, rock.cellY) == rock)
end
U.shot(game, SHOT_DIR .. "/bug809_walkup_stop.png")
-- ...and the rock still moves. Push it north, the one free direction left:
-- west is her, east is the player, south is where he came from.
holdUntil("down", playerAt(17, 4), 120)
holdUntil("left", playerAt(16, 4), 120)
holdUntil("up", boulderAt(16, 2), 400)
if not check("the boulder is still pushable after the engage",
rock.cellX == 16 and rock.cellY == 2) then
U.log("boulder ended at", rock.cellX, rock.cellY, "player at",
ow.player.cellX, ow.player.cellY)
end
holdUntil("down", playerAt(16, 4), 120)
U.shot(game, SHOT_DIR .. "/bug809_still_pushable.png")
ow.engageTrainer = realEngage
U.log(pass and "ALL CHECKS PASSED" or "SOME CHECKS FAILED")
U.log("On screen: the COOLTRAINER stands at (15,3) with a one-cell gap")
U.log("between her and the rock, which now sits at (16,2), one row up from")
U.log("where she stopped. The near miss to watch for is her sprite ending")
U.log("the walk-up on top of the rock, or standing clear of it but leaving")
U.log("it inert: walk into the rock from any side and it should still shift")
U.log("a cell. Her battle was stubbed out and she is flagged as beaten;")
U.log("re-run the driver to watch the race again from the start.")
while true do
coroutine.yield()
end
end
+209
View File
@@ -0,0 +1,209 @@
-- Driver: Fighting Dojo prize balls, #853 (dex page first) and #854 (the
-- question stays on screen under YES/NO). pokered scripts/FightingDojo.asm
-- runs `ld a, HITMONLEE / call DisplayPokedex` before .Text, and .Text is a
-- text_end string printed with PrintText immediately followed by YesNoChoice.
-- No POKEPORT_SPEED here: the dex page and the YES/NO pop are what is judged.
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/dojo_balls_bug853_test.lua POKEPORT_IDENTITY=bug853 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local DexEntryMenu = require("src.ui.DexEntryMenu")
local MapScripts = require("src.script.MapScripts")
local Screens = require("src.ui.Screens")
local OW = require("src.world.OverworldController")
local Pokemon = require("src.pokemon.Pokemon")
-- pokered data/maps/objects/FightingDojo.asm: the two SPRITE_POKE_BALL
-- objects sit at (4, 1) HITMONLEE and (5, 1) HITMONCHAN, on the north wall
-- under the posters. The only approach is from the mat below them.
local MAP = "FIGHTING_DOJO"
local LEE = { name = "FIGHTINGDOJO_HITMONLEE_POKE_BALL", x = 4, y = 1 }
local CHAN = { name = "FIGHTINGDOJO_HITMONCHAN_POKE_BALL", x = 5, y = 1 }
local START = { x = 4, y = 4 } -- walk up from here to (4, 2), facing LEE
local failures = {}
local function check(cond, msg)
if cond then U.log("PASS", msg) else
failures[#failures + 1] = msg
U.log("FAIL", msg)
end
return cond
end
local function topIs(mt) return getmetatable(game.stack:top()) == mt end
local function under()
return game.stack.states[#game.stack.states - 1]
end
local function npcByName(ow, name)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == name then return n end
end
end
local function pageText()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return "" end
local page = top.pages and top.pages[top.pageIndex]
return page and table.concat(page, "\n") or ""
end
local function waitFor(cond, cap)
for _ = 1, (cap or 200) do
if cond() then return true end
U.wait(2)
end
return cond()
end
local function mashUntil(cond, cap)
for _ = 1, (cap or 100) do
if cond() then return true end
U.tap(game, "a")
U.wait(2)
end
return cond()
end
-- fresh dojo with the master already beaten and neither prize taken
local function seed(x, y, facing)
while game.stack:top() do game.stack:pop() end
game.save.flags = {
EVENT_BEAT_KARATE_MASTER = true,
EVENT_BEAT_FIGHTING_DOJO_TRAINER_0 = true,
EVENT_BEAT_FIGHTING_DOJO_TRAINER_1 = true,
EVENT_BEAT_FIGHTING_DOJO_TRAINER_2 = true,
EVENT_BEAT_FIGHTING_DOJO_TRAINER_3 = true,
}
game.save.defeatedTrainers = { FIGHTING_DOJO_obj_1 = true }
game.save.objectToggles = {}
game.save.player.name = game.save.player.name or "RED"
-- one mon so give_pokemon has a party to append to, and room for a prize
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
game.stack:push(OW, MAP, x, y, facing or "up")
U.wait(10)
return game.stack:top()
end
local ow = seed(START.x, START.y, "up")
------------------------------------------------------------------
-- machine-checkable half: seed, objects, script rows, text, screen id
------------------------------------------------------------------
check(game.save.flags.EVENT_BEAT_KARATE_MASTER == true,
"EVENT_BEAT_KARATE_MASTER is set (the balls answer at all)")
check(not game.save.flags.EVENT_GOT_HITMONLEE
and not game.save.flags.EVENT_GOT_HITMONCHAN,
"neither prize taken yet (no 'greedy' refusal path)")
local leeBall, chanBall = npcByName(ow, LEE.name), npcByName(ow, CHAN.name)
check(leeBall ~= nil, "HITMONLEE ball object loaded")
check(chanBall ~= nil, "HITMONCHAN ball object loaded")
check(leeBall and leeBall.cellX == LEE.x and leeBall.cellY == LEE.y,
("HITMONLEE ball sits at the asm cell (%d, %d)"):format(LEE.x, LEE.y))
check(chanBall and chanBall.cellX == CHAN.x and chanBall.cellY == CHAN.y,
("HITMONCHAN ball sits at the asm cell (%d, %d)"):format(CHAN.x, CHAN.y))
check(type(MapScripts.talkScript(MAP, "TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL"))
== "function",
"TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL has a hand-ported talk script")
check(type(MapScripts.talkScript(MAP, "TEXT_FIGHTINGDOJO_HITMONCHAN_POKE_BALL"))
== "function",
"TEXT_FIGHTINGDOJO_HITMONCHAN_POKE_BALL has a hand-ported talk script")
-- the ask() string is the extracted descriptor, not the "You want X?" stub
local leeText = game.data.text._FightingDojoHitmonleePokeBallText
local chanText = game.data.text._FightingDojoHitmonchanPokeBallText
check(type(leeText) == "string" and leeText ~= "",
"_FightingDojoHitmonleePokeBallText resolves")
check(type(chanText) == "string" and chanText ~= "",
"_FightingDojoHitmonchanPokeBallText resolves")
if type(leeText) == "string" then
U.log("lee prompt reads:", (leeText:gsub("\n", " / ")))
end
local dexOk = pcall(Screens.get, game, "DexEntryMenu")
check(dexOk, "DexEntryMenu resolves through the Screens registry")
------------------------------------------------------------------
-- rehearsal on the HITMONCHAN ball, answered NO so nothing is consumed
------------------------------------------------------------------
if chanBall then
ow:talkTo(chanBall)
check(waitFor(function() return topIs(DexEntryMenu) end, 60),
"#853: the ball opens the HITMONCHAN dex page before any question")
U.shot(game, DIR .. "/dojo_balls_1_dex.png")
U.tap(game, "b")
check(waitFor(function() return topIs(TextBox) end, 60),
"#853: closing the dex page leads into the offer text")
mashUntil(function() return topIs(ChoiceBox) end, 60)
check(topIs(ChoiceBox), "#854: the YES/NO menu opens on the offer")
check(getmetatable(under()) == TextBox,
"#854: the question box is still on the stack under the YES/NO menu")
U.shot(game, DIR .. "/dojo_balls_2_choice.png")
U.tap(game, "b") -- B answers NO; the prize stays unclaimed
waitFor(function() return game.stack:top() == ow end, 120)
check(not game.save.flags.EVENT_GOT_HITMONCHAN,
"answering NO leaves the HITMONCHAN prize unclaimed")
check(#game.save.party == 1, "answering NO adds nothing to the party")
end
------------------------------------------------------------------
-- hand-off: walk to the HITMONLEE ball and open it for real
------------------------------------------------------------------
ow = seed(START.x, START.y, "up")
for _ = 1, 12 do
if ow.player.cellY <= LEE.y + 1 then break end
U.hold(game, "up", 16)
U.wait(4)
end
local function facingTheBall()
local cur = game.overworld
local ball = cur and npcByName(cur, LEE.name)
if not ball then return false end
local fx, fy = cur.player:facingCell()
return cur:npcAtCell(fx, fy) == ball
end
if not facingTheBall() then
-- a map edit or a mod moved the ball: stand on any free walkable
-- neighbour instead. {dx, dy, facing} is the offset from the ball to
-- the stand cell plus the direction that looks back at it.
local sides = {
{ 0, 1, "up" }, { 1, 0, "left" }, { -1, 0, "right" }, { 0, -1, "down" },
}
for _, s in ipairs(sides) do
local cx, cy = LEE.x + s[1], LEE.y + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log("walk up stopped short; standing on", cx, cy, "facing", s[3])
ow = seed(cx, cy, s[3])
break
end
end
end
check(facingTheBall(), "player is standing against the HITMONLEE ball")
U.tap(game, "a")
check(waitFor(function() return topIs(DexEntryMenu) end, 60),
"#853: pressing A opens the HITMONLEE dex page")
U.shot(game, DIR .. "/dojo_balls_3_handoff.png")
if #failures == 0 then
U.log("all checks passed")
else
U.log(("%d check(s) failed:"):format(#failures), table.concat(failures, "; "))
end
U.log("On screen now: the HITMONLEE dex page the ball opened, name and")
U.log("sprite only, since the mon is seen but not owned yet. Press B: the")
U.log("offer types out, and the YES/NO menu should appear above it with the")
U.log("question still readable -- the old bug swapped the text away for a")
U.log("bare YES/NO over the overworld. Answer YES to take HITMONLEE; the")
U.log("HITMONCHAN ball beside it stays put and gives the greedy refusal.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,321 @@
-- Driver: #862 Celadon Game Corner poster grunt, loss line + exit walk.
-- GameCornerRocketText saves _GameCornerRocketBattleEndText ("Dang!") for
-- PrintEndBattleText, and GameCornerRocketBattleScript picks the exit walk
-- from the player's cell (pokered/scripts/GameCorner.asm:54-102): east of
-- him it is WalkAroundPlayer, DOWN/R/R/UP/R/R/R/R, never UP into the poster.
-- No POKEPORT_SPEED: the walk and the battle text are what is under test.
-- SHOT_DIR=/tmp/shots POKEPORT_IDENTITY=bug862 POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/game_corner_grunt_bug862_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
os.execute("mkdir -p " .. DIR)
local Pokemon = require("src.pokemon.Pokemon")
local TextBox = require("src.render.TextBox")
local BattleState = require("src.battle.BattleState")
local pass, fail = 0, 0
local function check(label, ok, detail)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", label, detail or "")
return ok
end
-- pokered/data/maps/objects/GameCorner.asm:36 -- the grunt is
-- object_event 9, 5, SPRITE_ROCKET, STAY, UP, facing the poster bg_event
-- at (9,4), which is wall. Standing east of him on (10,5) is the branch
-- that matters: wYCoord ~= 6 and wXCoord ~= 8, so the script takes
-- GameCornerMovement_Rocket_WalkAroundPlayer.
local MAP = "GAME_CORNER"
local NAME = "GAMECORNER_ROCKET"
local GX, GY = 9, 5
local STAND = { x = 10, y = 5, facing = "left" }
local POSTER = { x = 9, y = 4 }
-- DOWN, RIGHT, RIGHT, UP, RIGHT x4 from (9,5), ending on (15,5)
local AROUND = {
{ 9, 6 }, { 10, 6 }, { 11, 6 }, { 11, 5 },
{ 12, 5 }, { 13, 5 }, { 14, 5 }, { 15, 5 },
}
-- clean slate: he must not read as already defeated or already hidden
game.save.defeatedTrainers = {}
game.save.objectToggles = game.save.objectToggles or {}
game.save.objectToggles.GAME_CORNER = nil
game.save.player = game.save.player or {}
game.save.player.name = game.save.player.name or "RED"
game.save.money = game.save.money or 3000
-- a tank that one-shots OPP_ROCKET #7, so the mash win below is quick and
-- the same every run whatever the type matchups are
local tank = Pokemon.new(game.data, "MEWTWO", 100)
tank.moves = {
{ id = "PSYCHIC_M", pp = 99 },
{ id = "THUNDERBOLT", pp = 99 },
{ id = "ICE_BEAM", pp = 99 },
{ id = "EARTHQUAKE", pp = 99 },
}
game.save.party = { tank }
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
local ow = game.overworld
local function findGrunt()
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == NAME then return n end
end
return nil
end
local grunt = findGrunt()
check("GAMECORNER_ROCKET is on the floor", grunt ~= nil)
if grunt then
check("he stands on (9,5)", grunt.cellX == GX and grunt.cellY == GY,
("at (%d,%d)"):format(grunt.cellX, grunt.cellY))
end
-- a map edit or a mod could take (10,5) away; anything east of him keeps
-- the WalkAroundPlayer branch, so fall back to a free walkable neighbour
-- and say which branch that lands on
local function facingGrunt()
local g = findGrunt()
if not g then return false end
local fx, fy = ow.player:facingCell()
return ow:npcAtCell(fx, fy) == g
end
if grunt and not facingGrunt() then
local sides = {
{ 1, 0, "left" }, { 0, 1, "up" }, { -1, 0, "right" }, { 0, -1, "down" },
}
for _, s in ipairs(sides) do
local cx, cy = grunt.cellX + s[1], grunt.cellY + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("(%d,%d) is blocked; standing on"):format(STAND.x, STAND.y),
cx, cy, "facing", s[3])
U.teleport(game, MAP, cx, cy, s[3])
ow = game.overworld
grunt = findGrunt()
break
end
end
end
check("the player is face to face with him", facingGrunt())
local px, py = ow.player.cellX, ow.player.cellY
local around = not (py == 6 or px == 8)
U.log(("talking from (%d,%d): the script should take %s"):format(
px, py, around and "WalkAroundPlayer (down, right, right, up, "
.. "right x4)" or "WalkDirect (right x5)"))
-- the two strings the fix depends on, and the poster cell the pre-fix
-- single UP step walked him into
local t = game.data.text
check("_GameCornerRocketBattleEndText resolves",
type(t._GameCornerRocketBattleEndText) == "string"
and t._GameCornerRocketBattleEndText ~= "",
tostring(t._GameCornerRocketBattleEndText))
check("_GameCornerRocketAfterBattleText resolves",
type(t._GameCornerRocketAfterBattleText) == "string"
and t._GameCornerRocketAfterBattleText ~= "")
check("(9,4) is the poster wall, not a cell he can stand on",
not ow.map:isWalkableCell(POSTER.x, POSTER.y))
-- engageTrainer has to accept the script-supplied loss line; a stale
-- two-parameter copy would silently drop it and print nothing
local info = debug.getinfo(ow.engageTrainer, "S")
local sigOk = false
if info and info.short_src then
local src = io.open((info.short_src:gsub("^@", "")), "r")
if src then
local n = 0
for line in src:lines() do
n = n + 1
if n == info.linedefined then
sigOk = line:find("endBattleText", 1, true) ~= nil
break
end
end
src:close()
end
end
check("engageTrainer takes an endBattleText argument", sigOk)
U.shot(game, DIR .. "/bug862_0_before.png")
-- Talk and mash to a win, recording every battle message in order and
-- pausing on the loss line long enough to photograph it.
local said, battle = {}, nil
local lastSaid, dangShot = nil, false
local function sample()
local top = game.stack:top()
if getmetatable(top) == BattleState then
battle = battle or top
local cur = top.current
local text = type(cur) == "table" and cur.text
if type(text) == "string" and text ~= lastSaid then
lastSaid = text
said[#said + 1] = text
U.log("battle says:", (text:gsub("\n", " ")))
end
end
end
local function pageText()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return "" end
local parts = {}
for _, page in ipairs(top.pages or {}) do
if type(page) == "table" then
for _, line in ipairs(page) do parts[#parts + 1] = tostring(line) end
end
end
return table.concat(parts, " ")
end
local function idle()
return game.stack:top() == ow and not ow.runner:isRunning()
and #ow.scriptMoves == 0 and not ow.transitioning
end
U.tap(game, "a")
local sawAfter = false
for f = 1, 4000 do
sample()
if pageText():find("hideout", 1, true) then sawAfter = true break end
local top = game.stack:top()
if lastSaid and lastSaid:find("Dang", 1, true) and not dangShot then
-- stop mashing for a moment: the loss line is on the battle screen.
-- The row is picked up the frame it starts typing, so let it finish
-- before the capture or the shot is one letter wide.
dangShot = true
U.wait(60)
U.shot(game, DIR .. "/bug862_1_dang.png")
elseif top and top.phase then
if top.phase == "menu" then top.menuIndex = 1
elseif top.phase == "moveSelect" then top.moveIndex = 1 end
U.tap(game, "a")
if f > 2400 and top.onFinish then
U.log("force-finishing a stalled battle")
top.onFinish("win")
if game.stack:top() == top then game.stack:pop() end
end
else
U.tap(game, "a")
end
U.wait(2)
sample()
end
check("reached the after-battle 'hideout' line", sawAfter)
check("the battle carried the script's loss line",
battle ~= nil and type(battle.endBattleText) == "string"
and battle.endBattleText:find("Dang", 1, true) ~= nil,
battle and tostring(battle.endBattleText) or "no battle seen")
-- PrintEndBattleText sits between TrainerDefeatedText and
-- MoneyForWinningText (engine/battle/core.asm TrainerBattleVictory)
local iDefeat, iDang, iMoney
for i, line in ipairs(said) do
if not iDefeat and line:find("defeated", 1, true) then iDefeat = i end
if not iDang and line:find("Dang", 1, true) then iDang = i end
if not iMoney and line:find("winning", 1, true) then iMoney = i end
end
check("the loss line printed on the battle screen", iDang ~= nil)
check("it printed with the ROCKET: name tag",
iDang ~= nil and said[iDang]:find(":", 1, true) ~= nil,
iDang and said[iDang] or "")
check("order is defeated -> Dang! -> payout",
iDefeat ~= nil and iDang ~= nil and iMoney ~= nil
and iDefeat < iDang and iDang < iMoney,
("defeated=%s dang=%s payout=%s"):format(tostring(iDefeat),
tostring(iDang),
tostring(iMoney)))
U.shot(game, DIR .. "/bug862_2_afterbattle.png")
-- Dismiss the after-battle box and watch the exit walk cell by cell.
U.tap(game, "a")
local visited, order, lowShot = {}, {}, false
local function mark(cx, cy)
local key = cx .. "," .. cy
if not visited[key] then
visited[key] = true
order[#order + 1] = key
end
end
-- the last step's hide_object rides its own onDone, so the grunt leaves
-- ow.npcs on the frame he lands: count the cell he is walking INTO as
-- visited too, or the destination never shows up in the sample
local last = { GX, GY }
for _ = 1, 900 do
local g = findGrunt()
if g then
mark(g.cellX, g.cellY)
last = { g.cellX, g.cellY }
if g.targetX and g.targetY then
mark(g.targetX, g.targetY)
last = { g.targetX, g.targetY }
end
if g.cellY > GY and not lowShot then
lowShot = true
U.shot(game, DIR .. "/bug862_3_walk.png")
end
elseif idle() then
break
end
if game.stack:top() ~= ow then U.tap(game, "a") end
U.wait(1)
end
for _ = 1, 400 do
if idle() then break end
if game.stack:top() ~= ow then U.tap(game, "a") end
U.wait(2)
end
U.wait(5)
U.shot(game, DIR .. "/bug862_4_gone.png")
U.log("cells he stood on:", table.concat(order, " "))
check("he never stood on the poster cell (9,4)",
not visited[POSTER.x .. "," .. POSTER.y])
check("he never stepped north of his start row", (function()
for key in pairs(visited) do
local y = tonumber(key:match(",(%d+)$"))
if y and y < GY then return false end
end
return true
end)())
if around then
check("he stepped down to (9,6) to get past the player", visited["9,6"])
check("he came back up onto row 5 and finished on (15,5)",
last[1] == 15 and last[2] == 5,
("last seen on (%d,%d)"):format(last[1], last[2]))
else
check("he walked straight along row 5 to (15,5)",
last[1] == 15 and last[2] == 5 and not visited["9,6"],
("last seen on (%d,%d)"):format(last[1], last[2]))
end
local toggles = game.save.objectToggles.GAME_CORNER
check("he despawned only after the last step", findGrunt() == nil)
check("his objectToggle is hidden",
toggles ~= nil and toggles.GAMECORNER_ROCKET == false)
check("he is recorded as defeated",
game.save.defeatedTrainers["GAME_CORNER_obj_11"] == true)
U.log(("checks: %d passed, %d failed"):format(pass, fail))
-- Hand the pad over on a clean copy of the same setup so the whole beat
-- can be watched at speed.
game.save.defeatedTrainers = {}
game.save.objectToggles.GAME_CORNER = nil
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.log("You are east of the grunt again, facing him. Press A and win.")
U.log("Right looks like: he says his piece on the battle screen after")
U.log("\"RED defeated ROCKET!\" -- one box, \"ROCKET: Dang!\" -- and the")
U.log("¥ payout comes after it, not before. Then the hideout line, then")
U.log("he steps DOWN off row 5, right past you, back up and out east.")
U.log("The near miss to watch for: he steps UP into the poster, or the")
U.log("Dang! box turns up in the overworld after the battle has torn down.")
U.log("Talk to him from (9,6) below instead and he takes the straight")
U.log("five-step version east; both are correct, the branch is your cell.")
while true do
coroutine.yield()
end
end
+177
View File
@@ -0,0 +1,177 @@
-- Manual check of the Rocket Hideout B4F Jessie & James ambush: James walks
-- the full four tiles to the player's side (#865) and their loss line prints
-- on the battle screen before the prize money (#866).
-- pokeyellow scripts/RocketHideoutB4F.asm (MovementData_45605 falls through
-- into _45606) and data/maps/objects/RocketHideoutB4F.asm. No fast-forward:
-- POKEPORT_DRIVER=tests/drivers/jessie_james_bug866_test.lua POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local Commands = require("src.script.Commands")
local GameVersion = require("src.core.GameVersion")
local mapScripts = require("data.scripts.init")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local MAP = "ROCKET_HIDEOUT_B4F"
local BEAT = "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES"
local JAMES, JESSIE = "ROCKETHIDEOUTB4F_JAMES", "ROCKETHIDEOUTB4F_JESSIE"
-- RocketHideoutB4FScript_455a5 fires on wYCoord $e with wXCoord $18 or $19.
-- x=24 leaves EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT clear, which is
-- the branch that hands the four-step blob to James (object 2, spawned at
-- 25,10) and the three-step one to Jessie (object 3, at 24,10).
local TRIGGER = { x = 24, y = 14 }
local EXPECT = {
[JAMES] = { x = 25, y = 14, facing = "left" },
[JESSIE] = { x = 24, y = 13, facing = "down" },
}
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
check("running the Yellow cache (the duo exists nowhere else)",
GameVersion.isYellow())
if not GameVersion.isYellow() then
U.log("re-run with POKEPORT_VERSION=yellow; nothing below will be true")
end
local hooks = mapScripts.get(MAP)
check("yellow_jessie_james registered an onStep for " .. MAP,
type(hooks) == "table" and type(hooks.onStep) == "function")
check("their talk entries are registered too",
type(hooks) == "table" and type(hooks.talk) == "table"
and hooks.talk.TEXT_ROCKETHIDEOUTB4F_JAMES ~= nil
and hooks.talk.TEXT_ROCKETHIDEOUTB4F_JESSIE ~= nil)
-- the #866 fix is a new script verb; if a mod shadowed it or the registry
-- never picked it up, the row would silently no-op and the line would come
-- back after the money instead of before it
local verb = Commands.resolve(game.data, "save_end_battle_text")
check("save_end_battle_text resolves as a script verb", type(verb) == "function")
local texts = {}
for i = 1, 4 do
local key = "_RocketHideoutJessieJamesText" .. i
texts[i] = game.data.text[key]
check(key .. " resolves to a string",
type(texts[i]) == "string" and texts[i] ~= "")
end
if type(texts[3]) == "string" then
U.log("the armed loss line reads:", (texts[3]:gsub("\n", " / ")))
end
local objs = (game.data.maps[MAP] or {}).objects or {}
local defs = {}
for _, o in ipairs(objs) do
if o.name == JAMES or o.name == JESSIE then defs[o.name] = o end
end
check("James is object 2 of " .. MAP .. ", hidden at (25,10)",
defs[JAMES] ~= nil and defs[JAMES].index == 2
and defs[JAMES].x == 25 and defs[JAMES].y == 10
and defs[JAMES].hidden == true)
check("Jessie is object 3, hidden at (24,10)",
defs[JESSIE] ~= nil and defs[JESSIE].index == 3
and defs[JESSIE].x == 24 and defs[JESSIE].y == 10
and defs[JESSIE].hidden == true)
local rocket = game.data.trainers.OPP_ROCKET
check("OPP_ROCKET party 43 (the duo's shared team) exists",
rocket ~= nil and rocket.parties ~= nil and rocket.parties[43] ~= nil)
-- arm the site: the ambush is gated only on its beat flag, so no story
-- progress is needed to make it live
game.save.flags[BEAT] = nil
game.save.flags.EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT = nil
check(BEAT .. " cleared, so the trigger is live",
game.save.flags[BEAT] == nil)
-- a real party, because the human has to win the battle for the loss line
-- to print at all
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 60),
Pokemon.new(game.data, "NIDOKING", 58),
Pokemon.new(game.data, "STARMIE", 58),
}
game.save.player.name = "RED"
-- walk in from the north; the two elevator warps sit on row 15, so the
-- approach cannot come from below
U.teleport(game, MAP, TRIGGER.x, TRIGGER.y - 1, "down")
local ow = game.overworld
if not ow.map:isWalkableCell(TRIGGER.x, TRIGGER.y - 1) then
-- a map edit moved the free cell: any walkable neighbour of the trigger
-- works, the script only reads the tile the player lands on
local sides = { { 0, -1, "down" }, { -1, 0, "right" }, { 1, 0, "left" } }
for _, s in ipairs(sides) do
local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2]
if ow.map:isWalkableCell(cx, cy) then
U.log("standing on", cx, cy, "facing", s[3], "instead")
U.teleport(game, MAP, cx, cy, s[3])
ow = game.overworld
U.hold(game, s[3] == "down" and "down" or (s[3] == "right" and "right" or "left"), 20)
break
end
end
else
U.hold(game, "down", 20)
end
U.wait(10)
check("player stepped onto the trigger tile (24,14)",
ow.player.cellX == TRIGGER.x and ow.player.cellY == TRIGGER.y)
check("the ambush script is running", ow.runner:isRunning())
U.log("The cutscene is yours now: press A to read, then fight and win.")
U.log("Right looks like both Rockets closing in -- Jessie stopping one tile")
U.log("above you, James coming all the way down to stand at your right -- and")
U.log("after you win, \"ROCKET: Such a dreadful twerp!\" appearing on the")
U.log("battle screen just before the money line. The near-miss to watch for")
U.log("is James halting three tiles up by the wall, or that line showing up")
U.log("in the overworld box after the payout with no ROCKET: tag on it.")
U.log("Two more checks print below as you get to them.")
local function npcNamed(name)
for _, n in ipairs(game.overworld and game.overworld.npcs or {}) do
if n.def and n.def.name == name then return n end
end
return nil
end
local approachDone, battleSeen = false, false
while true do
if not approachDone then
local j, s = npcNamed(JAMES), npcNamed(JESSIE)
local ow2 = game.overworld
if j and s and not j.moving and not s.moving and ow2
and #(ow2.scriptMoves or {}) == 0
and (j.cellY > 10 or s.cellY > 10) then
approachDone = true
check("James walked the full four tiles to (25,14) facing left",
j.cellX == EXPECT[JAMES].x and j.cellY == EXPECT[JAMES].y
and j.facing == EXPECT[JAMES].facing)
check("Jessie stopped three down at (24,13) facing the player",
s.cellX == EXPECT[JESSIE].x and s.cellY == EXPECT[JESSIE].y
and s.facing == EXPECT[JESSIE].facing)
U.log("James at", j.cellX, j.cellY, j.facing,
"Jessie at", s.cellX, s.cellY, s.facing)
U.shot(game, SHOT_DIR .. "/jj866_approach.png")
end
end
if not battleSeen then
local top = game.stack:top()
if getmetatable(top) == BattleState then
battleSeen = true
-- BattleState prints endBattleText between _TrainerDefeatedText and
-- _MoneyForWinningText, so an armed field IS the ordering fix
check("the battle carries the loss line as its end-battle text",
type(top.endBattleText) == "string" and top.endBattleText ~= ""
and top.endBattleText == texts[3])
if type(top.endBattleText) == "string" then
U.log("armed:", (top.endBattleText:gsub("\n", " / ")))
end
end
end
coroutine.yield()
end
end
+211
View File
@@ -0,0 +1,211 @@
-- Disable blocks the move the slower mon ALREADY selected, on the very
-- turn the Disable lands (#860). pokered runs the test at execution
-- time, not at selection time: CheckPlayerStatusConditions
-- .TriedToUseDisabledMoveCheck (engine/battle/core.asm:3437-3447) compares
-- wPlayerDisabledMoveNumber against wPlayerSelectedMove and jumps to
-- ExecutePlayerMoveDone when they match -- "prevents a disabled move that
-- was selected before being disabled from being used", in the asm's own
-- comment. The enemy copy is .checkIfTriedToUseDisabledMove
-- (core.asm:5752+). The port only refused a disabled move at menu time,
-- so the second mover still fired the move it had latched before the
-- Disable resolved.
--
-- The check sits after the confusion block and before the paralysis roll,
-- so this suite also pins the neighbours: the counter tick that clears an
-- expired Disable still runs first, and a move that was never disabled is
-- untouched.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Font = require("src.render.Font")
Font.load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
-- the fixture dataset has no status move; this dataset is this file's own
-- copy (fixtures.fresh), so registering one here cannot leak into another
-- case. Accuracy 100 keeps DisableEffect's MoveHitTest out of the way.
Data.moves.FIX_DISABLE = {
id = "FIX_DISABLE", index = 90, name = "FIX DISABLE",
type = "NORMAL", power = 0, accuracy = 100, pp = 20,
effect = "DISABLE_EFFECT",
}
-- Deterministic rolls: the minimum of every range, except DisableEffect's
-- own "1-8 turns disabled" roll (effects.asm:1343-1345), which is pinned
-- at 4. A rolled 1 would be spent by the disabled mon's own counter tick
-- in the same CheckStatusConditions pass -- vanilla behaviour, but it
-- clears the disable before .TriedToUseDisabledMoveCheck can see it, so it
-- is not the case this suite is about. rng(0, 255) -> 0 makes every
-- accuracy roll hit.
local function rolls(disableTurns)
return function(a, b)
if a == 1 and b == 8 then return disableTurns end
if a then return a end
return 0
end
end
-- playerFirst decides who lands the Disable; the other side is the one
-- whose already-selected move has to die. Both mons get FIX_TACKLE in
-- slot 1 (the slot DisableEffect picks with the min roll) and FIX_SCRATCH
-- in slot 2 as the never-disabled control.
local function newBattle(playerFirst, disableTurns)
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 30)
battle.rng = rolls(disableTurns or 4)
local function loadout(battler)
battler.mon.moves = {
{ id = "FIX_TACKLE", pp = 35 },
{ id = "FIX_SCRATCH", pp = 35 },
{ id = "FIX_DISABLE", pp = 20 },
}
battler.curMoves = battler.mon.moves
end
loadout(battle.player)
loadout(battle.enemy)
-- no speed tie to resolve: the disabler outruns its target outright
battle.player.curStats.speed = playerFirst and 200 or 1
battle.enemy.curStats.speed = playerFirst and 1 or 200
return battle
end
-- the move instances the sides actually own, so PP decrements land on
-- the party copy the way DecrementPP mutates wBattleMonPP
local function slot(battler, i) return battler.curMoves[i] end
-- consume the queue the way updateQueue does, minus the presentation
local function drain(battle)
local rows = {}
for _ = 1, 400 do
local item = table.remove(battle.queue, 1)
if not item then return rows end
if item.text then rows[#rows + 1] = { text = item.text } end
if item.fn then
battle.nextInsert = 0
item.fn()
end
end
error("the turn queue never drained")
end
local function saidWith(rows, needle)
for i, row in ipairs(rows) do
if row.text and row.text:find(needle, 1, true) then return i end
end
return nil
end
-- "X's / MOVE is / disabled!" (PrintMoveIsDisabledText) versus
-- DisableEffect's own "MOVE was / disabled!" -- the two lines differ only
-- in that verb, so match on it
local function blocked(rows) return saidWith(rows, "is\ndisabled!") end
local function landed(rows) return saidWith(rows, "was\ndisabled!") end
local function usedTackle(rows) return saidWith(rows, "used FIX TACKLE!") end
-- ---------------------------------------------------------------------
-- the player Disables first; the foe's latched FIX TACKLE dies this turn
-- ---------------------------------------------------------------------
do
local battle = newBattle(true)
battle.enemyAction = function() return slot(battle.enemy, 1) end
local hpBefore = battle.player.mon.hp
battle:resolveTurn(slot(battle.player, 3))
local rows = drain(battle)
T.check(landed(rows) ~= nil, "the Disable lands")
T.eq(battle.enemy.disabledSlot, 1, "and latches onto the foe's slot 1")
T.check(blocked(rows) ~= nil,
"the foe's already-selected move reports as disabled")
T.check(landed(rows) and blocked(rows) and landed(rows) < blocked(rows),
"in that order: disabled first, then the blocked attempt")
T.check(usedTackle(rows) == nil,
"the disabled move is never announced, so it never executed")
T.eq(battle.player.mon.hp, hpBefore, "and it deals no damage")
T.eq(battle.enemy.disabledTurns, 3,
"the counter ticked once for this turn and the disable is still live")
end
-- ---------------------------------------------------------------------
-- the same, mirrored: the foe Disables first and the player's latched
-- move dies (core.asm:5752 .checkIfTriedToUseDisabledMove)
-- ---------------------------------------------------------------------
do
local battle = newBattle(false)
battle.enemyAction = function() return slot(battle.enemy, 3) end
local hpBefore = battle.enemy.mon.hp
local ppBefore = slot(battle.player, 1).pp
battle:resolveTurn(slot(battle.player, 1))
local rows = drain(battle)
T.check(landed(rows) ~= nil, "the foe's Disable lands")
T.eq(battle.player.disabledSlot, 1, "on the player's slot 1")
T.check(blocked(rows) ~= nil, "the player's latched move reports as disabled")
T.check(usedTackle(rows) == nil, "and is never announced")
T.eq(battle.enemy.mon.hp, hpBefore, "the foe takes no damage")
T.eq(slot(battle.player, 1).pp, ppBefore,
"and the move that never executed spends no PP (DecrementPP is inside "
.. "the move, past the status gauntlet)")
end
-- ---------------------------------------------------------------------
-- no regression on the turns after: the disable keeps blocking that move
-- while its counter runs, and a different move still works
-- ---------------------------------------------------------------------
do
local battle = newBattle(true)
battle.enemyAction = function() return slot(battle.enemy, 1) end
battle:resolveTurn(slot(battle.player, 3))
drain(battle)
T.eq(battle.enemy.disabledTurns, 3, "the disable is live going into turn 2")
-- turn 2: the foe picks the disabled move with no Disable in flight
local hpBefore = battle.player.mon.hp
battle:resolveTurn(slot(battle.player, 2))
local rows = drain(battle)
T.check(blocked(rows) ~= nil, "turn 2 still blocks the disabled move")
T.check(usedTackle(rows) == nil, "still no execution")
T.eq(battle.player.mon.hp, hpBefore, "still no damage")
T.eq(battle.enemy.disabledTurns, 2, "and the counter keeps ticking down")
-- turn 3: the foe picks its OTHER move, which was never disabled
battle.enemyAction = function() return slot(battle.enemy, 2) end
hpBefore = battle.player.mon.hp
rows = (function() battle:resolveTurn(slot(battle.player, 2)); return drain(battle) end)()
T.check(blocked(rows) == nil, "an undisabled move is not blocked")
T.check(saidWith(rows, "used FIX SCRATCH!") ~= nil, "it is announced")
T.check(battle.player.mon.hp < hpBefore, "and it deals damage")
end
-- ---------------------------------------------------------------------
-- the counter tick still runs ahead of the check: a disable that expires
-- on this turn frees the move it was holding (.DisabledCheck precedes
-- .TriedToUseDisabledMoveCheck)
-- ---------------------------------------------------------------------
do
local battle = newBattle(true)
battle.enemy.disabledSlot, battle.enemy.disabledTurns = 1, 1
battle.enemyAction = function() return slot(battle.enemy, 1) end
local hpBefore = battle.player.mon.hp
battle:resolveTurn(slot(battle.player, 2))
local rows = drain(battle)
T.check(saidWith(rows, "disabled no more!") ~= nil, "the disable expires")
T.check(blocked(rows) == nil, "so the move is not blocked")
T.check(usedTackle(rows) ~= nil, "it executes")
T.check(battle.player.mon.hp < hpBefore, "and deals damage")
end
T.finish("disable blocks the already-selected move (#860)")
@@ -110,4 +110,92 @@ local last = Logger.history[#Logger.history]
check(last and last:find("options save failed", 1, true) ~= nil,
"the last failure logged is the false-return one, not the readback one")
-- ---- an interrupted write no longer resets every setting
-- The launcher wrote WIDE and a later write dies partway through (the
-- process replaced by HostShell.restart on the way back to the launcher, an
-- external-storage flush that never happened), leaving a corrupt
-- options.lua. loadOptions must promote the staged/backup copy instead of
-- answering defaults, which is what "closing the game reset all my
-- settings" looked like.
local live = memfs("honest")
SaveData.saveOptions({ battleLayout = "wide" }, live)
SaveData.saveOptions({ battleLayout = "wide", textSpeed = 1 }, live)
check(live.files[OPTIONS .. ".bak"] ~= nil,
"the previous good options.lua is rolled aside before the rewrite")
check(live.files[OPTIONS .. ".tmp"] == nil,
"the staged witness is dropped once the main write is verified")
live.files[OPTIONS] = "return { battleLayout = " -- died mid-rewrite
local healed = SaveData.loadOptions(live)
eq(healed and healed.battleLayout, "wide",
"a corrupt options.lua is recovered from the rolled-aside copy")
check(live.files[OPTIONS] ~= "return { battleLayout = ",
"the main options file is healed from the copy that parsed")
local gone = memfs("honest")
SaveData.saveOptions({ battleLayout = "wide" }, gone)
gone.files[OPTIONS] = nil
gone.files[OPTIONS .. ".bak"] = nil
gone.files[OPTIONS .. ".tmp"] = nil
eq(SaveData.loadOptions(gone).battleLayout,
SaveData.defaultOptions().battleLayout,
"with no copy left the defaults are still the answer")
-- ---- the reported sequence end to end: launcher setting -> play -> quit
-- #828 as the reporter walks it (issue steps 2-7, and the "so its partly
-- fixed" comment): change BATTLE LAYOUT from OG to WIDE in the launcher, go
-- in game, close, reopen the launcher. Every options write is a whole-file
-- rewrite out of the caller's table (saveOptions above), so the only thing
-- keeping the launcher's key alive across a game-side write is WHEN the game
-- took its copy: SaveData.load re-attaches a fresh loadOptions() to the save
-- it just read (src/core/SaveData.lua:1108, and SaveData.newGame does the
-- same at :1458), which is after the launcher's last write because
-- RomImporter:play hands off only once the settings modal has saved
-- (src/import/LauncherSettings.lua open/save, src/import/RomImporter.lua
-- play). This pins that ordering: it is the invariant, not the merge, that
-- makes the launcher's change survive.
local hop = memfs("honest")
SaveData.saveOptions({ battleLayout = "og" }, hop)
-- launcher: the gear menu's edited table, persisted on close
local launcherOpts = SaveData.loadOptions(hop)
launcherOpts.battleLayout = "wide"
launcherOpts.lastVersion = "blue" -- #835 rides the same file
SaveData.saveOptions(launcherOpts, hop)
-- boot: the game's copy is taken here, never earlier
local gameOpts = SaveData.loadOptions(hop)
eq(gameOpts.battleLayout, "wide",
"the game boots on the value the launcher just wrote")
-- play: an in-game OPTION menu change writes the whole table back
gameOpts.textSpeed = 1
check(SaveData.saveOptions(gameOpts, hop) ~= nil, "the game-side write lands")
local reopened = SaveData.loadOptions(hop)
eq(reopened.battleLayout, "wide",
"the launcher's BATTLE LAYOUT survives a game-side options write (#828)")
eq(reopened.textSpeed, 1, "and the in-game change is persisted alongside it")
eq(reopened.lastVersion, "blue",
"launcher-only keys the game never reads are carried through its write")
-- The corollary, and the reason the copy has to come from loadOptions: a
-- caller that writes a partial literal instead of a loaded table drops every
-- key it does not mention, because mergeOptions only fills DEFAULTS in around
-- what it is handed (SaveData.mergeOptions). Nothing on the boot path does
-- this today; the assertion is the guard rail if someone shortcuts it.
SaveData.saveOptions({ battleLayout = "og" }, hop)
eq(SaveData.loadOptions(hop).lastVersion, nil,
"a partial write drops launcher-only keys, so the game must write the "
.. "table loadOptions handed it")
-- Known gap, deliberately not asserted: a copy taken BEFORE the launcher's
-- write and flushed after it still wins, because saveOptions merges only
-- modOptions from disk and every other key is last-writer-wins. Measured,
-- not guessed (og beats a newer wide). No shipping path holds an options
-- table across a launcher write -- HostShell.restart replaces the process on
-- the way back to the launcher (#785, #575) and LauncherSettings.open notes
-- its own cached table is only true while its modal covers the launcher --
-- so closing that gap needs a three-way merge (baseline vs caller vs disk),
-- not a straight "disk wins", which would throw away real in-game changes.
T.finish("options_write_readback_bug828")