Compare commits

..

3 Commits

Author SHA1 Message Date
bryanthaboi 94be169e35 Merge pull request #13 from bryanthaboi/speedrun_conv_bug_fix
work in progress bug fix via speed run
2026-07-21 05:53:01 -04:00
bryanthaboi 36188ef18a work in progress 2026-07-21 05:50:40 -04:00
bryanthaboi 7a1b4e5c45 Merge pull request #10 from bryanthaboi/CI-update
CI Update
2026-07-20 09:22:48 -04:00
32 changed files with 9336 additions and 177 deletions
+10 -32
View File
@@ -1,22 +1,18 @@
-- Viridian City flavor dialogue (pokered/scripts/ViridianCity.asm).
-- Ports the text_asm bodies for GAMBLER1, YOUNGSTER2, GIRL and OLD_MAN.
-- Ports the text_asm bodies for GAMBLER1, YOUNGSTER2 and GIRL.
--
-- Not ported here (already handled elsewhere / not talk-reachable):
-- * TEXT_VIRIDIANCITY_FISHER (TM42 gift) -- already ported as a
-- `gift()` entry in data/scripts/story5.lua's M.VIRIDIAN_CITY.talk.
-- * TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY / TEXT_VIRIDIANCITY_GYM_LOCKED --
-- these are step-triggered blocking texts (ViridianCityCheckGotPokedexScript /
-- ViridianCityCheckGymOpenScript), not npc talk text_asm bodies; the
-- gates themselves are already implemented via story5.lua's onStep
-- chain (viridianOldManStep / viridianGymLock) for this map.
-- * The old man's catch-training minigame trigger (SCRIPT_VIRIDIANCITY_
-- OLD_MAN_START_CATCH_TRAINING / battle vs. WEEDLE) is a full
-- scripted-battle cutscene outside this task's Commands vocabulary
-- (no static_battle-style "battle a scripted old-man WEEDLE" command
-- exists); we port the real YES/NO branch text he speaks but the
-- "yes" branch here just shows the "I'll show you how" line rather
-- than actually starting the minigame, since that machinery isn't
-- ported to this map yet.
-- * TEXT_VIRIDIANCITY_OLD_MAN (the walking man at (17,5)) and
-- TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY (the sleeper at (18,9)) -- both
-- live in data/scripts/story.lua, which owns this map's onStep gate
-- and can reach the `old_man_demo` command for the real catch
-- tutorial. Keep them there: story.lua loads BEFORE this file, so a
-- duplicate here would silently win the merge.
-- * TEXT_VIRIDIANCITY_GYM_LOCKED -- a step-triggered blocking text
-- (ViridianCityCheckGymOpenScript), implemented by story5.lua's
-- onStep chain (viridianGymLock -> viridianOldManStep) for this map.
local M = {}
@@ -91,24 +87,6 @@ M.VIRIDIAN_CITY = {
end
end,
-- ViridianCityOldManText (scripts/ViridianCity.asm): once he's had
-- his coffee, he asks (YES/NO) whether you want to learn how to
-- catch Pokemon. YES leads into the catch-training minigame
-- (SCRIPT_VIRIDIANCITY_OLD_MAN_START_CATCH_TRAINING, not ported --
-- see file header); NO just brushes you off ("Time is money...").
TEXT_VIRIDIANCITY_OLD_MAN = function(game, ow, npc, done)
local t = text(game)
ask(game, t._ViridianCityOldManHadMyCoffeeNowText
or "Ahh, I've had my\ncoffee now and I\nfeel great!\nSure you can go\nthrough!\nAre you in a\nhurry?", function(yes)
if yes then
push(game, t._ViridianCityOldManKnowHowToCatchPokemonText
or "I see you're using\na POKéDEX.\nWhen you catch a\nPOKéMON, POKéDEX\nis automatically\nupdated.\nWhat? Don't you\nknow how to catch\nPOKéMON?\nI'll show you\nhow to then.", done)
else
push(game, t._ViridianCityOldManTimeIsMoneyText
or "Time is money...\nGo along then.", done)
end
end)
end,
},
}
+28 -21
View File
@@ -58,33 +58,40 @@ return {
TEXT_OAKSLAB_OAK1 = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
{ "jump_if_false", 12 }, -- 3
{ "jump_if_false", 14 }, -- 3
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
{ "jump_if_true", 12 }, -- 5
{ "jump_if_true", 14 }, -- 5
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
{ "jump", 30 }, -- 11
{ "check_flag", "EVENT_GOT_STARTER" }, -- 12
{ "jump_if_false", 27 }, -- 13
{ "check_item", "POKE_BALL" }, -- 14
{ "jump_if_true", 25 }, -- 15
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 16
{ "jump_if_false", 29 }, -- 17
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 18
{ "jump_if_true", 25 }, -- 19
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
{ "give_item", "POKE_BALL", 5, false }, -- 21
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 22
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 23
{ "jump", 30 }, -- 24
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 25
{ "jump", 30 }, -- 26
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 27
{ "jump", 30 }, -- 28
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 29 (30 = end)
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
-- Until this ran, the walking man at (17,5) -- who owns the coffee
-- ask and the catch tutorial -- stayed OFF for the whole game
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 11
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 12
{ "jump", 32 }, -- 13
{ "check_flag", "EVENT_GOT_STARTER" }, -- 14
{ "jump_if_false", 29 }, -- 15
{ "check_item", "POKE_BALL" }, -- 16
{ "jump_if_true", 27 }, -- 17
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 18
{ "jump_if_false", 31 }, -- 19
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
{ "jump_if_true", 27 }, -- 21
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
{ "give_item", "POKE_BALL", 5, false }, -- 23
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 24
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 25
{ "jump", 32 }, -- 26
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 27
{ "jump", 32 }, -- 28
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 29
{ "jump", 32 }, -- 30
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 31 (32 = end)
},
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
+79 -35
View File
@@ -10,6 +10,31 @@ local M = {}
-- -------------------------------------------------------------------
M.VIRIDIAN_MART = {
-- scripts/ViridianMart.asm: the parcel hand-off is the map's DEFAULT
-- script, not a talk. Entering with a starter and no parcel runs
-- ViridianMartDefaultScript -- the clerk calls out, then
-- StartSimulatingJoypadStates walks the player to the counter
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
-- and ViridianMartOaksParcelScript hands the parcel over. The player
-- never presses A. This matters beyond convenience: the parcel gates
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
-- it on entry rather than letting you walk out without it.
--
-- The talk branch below is kept as the fallback for a save that reaches
-- the counter without this having fired.
onEnter = function(game, ow)
local f = game.save.flags
if f.EVENT_OAK_GOT_PARCEL or f.EVENT_GOT_OAKS_PARCEL then return end
if not f.EVENT_GOT_STARTER then return end
ow:queueScript({
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
{ "move_player", "left", 1 },
{ "move_player", "up", 2 },
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
})
end,
talk = {
TEXT_VIRIDIANMART_CLERK = {
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1
@@ -33,42 +58,48 @@ M.VIRIDIAN_MART = {
M.VIRIDIAN_CITY = {
talk = {
-- the old man napping on the north path (scripts/ViridianCity.asm)
-- after his coffee, the old man offers the catch tutorial: not in a
-- hurry -> he demos catching a wild mon (BATTLE_TYPE_OLD_MAN)
-- The GAMBLER_ASLEEP at (18,9) (ViridianCityOldManSleepyText): he
-- only ever grumbles and shoves you back down -- he never wakes,
-- moves or hides. The coffee ask and the catch tutorial belong to
-- the *other* old man, the walking SPRITE_GAMBLER at (17,5)
-- (TEXT_VIRIDIANCITY_OLD_MAN, below). The two are swapped by the
-- Pokédex in data/scripts/oaks_lab.lua, not by talking to either.
TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY = {
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1
{ "jump_if_true", 5 }, -- 2
{ "show_text", "_ViridianCityOldManSleepyPrivatePropertyText" }, -- 3
{ "jump", 14 }, -- 4
{ "face_player" }, -- 5
{ "ask", "_ViridianCityOldManHadMyCoffeeNowText" }, -- 6
{ "jump_if_true", 12 }, -- 7 (in a hurry)
{ "show_text", "_ViridianCityOldManKnowHowToCatchPokemonText" }, -- 8
{ "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText" }, -- 9
{ "old_man_demo" }, -- 10
{ "jump", 13 }, -- 11
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 12
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
{ "show_text", "_ViridianCityOldManSleepyPrivatePropertyText" }, -- 1
{ "move_player", "down", 1 }, -- 2
},
-- The walking old man at (17,5), shown once the Pokédex swaps him in
-- (ViridianCityOldManText). "Are you in a hurry?" -- YES brushes you
-- off, NO leads into the catch tutorial: he explains, demos a catch
-- on a wild WEEDLE (BATTLE_TYPE_OLD_MAN), then comments afterwards.
-- pokered prints YouNeedToWeakenTheTarget *after* the demo battle
-- (ViridianCityOldManEndCatchTrainingScript), not before it.
TEXT_VIRIDIANCITY_OLD_MAN = {
{ "face_player" }, -- 1
{ "ask", "_ViridianCityOldManHadMyCoffeeNowText" }, -- 2
{ "jump_if_true", 8 }, -- 3 (yes = in a hurry)
{ "show_text", "_ViridianCityOldManKnowHowToCatchPokemonText" }, -- 4
{ "old_man_demo" }, -- 5
{ "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText" },-- 6
{ "jump", 9 }, -- 7
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end)
},
},
-- the sleeping old man lies across the Route 22 path (18,9): until
-- he moves you can't slip past on either side (scripts/ViridianCity
-- blocks the whole corridor, not just his tile)
-- ViridianCityCheckGotPokedexScript: the north corridor is gated on
-- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers
-- on exactly one cell -- (19,9), the gap east of the sleeper (18,9)
-- and the girl (17,9). With the Pokédex the check returns immediately
-- and you simply walk past at x=19.
onStep = function(game, ow, x, y)
local gone = game.save.objectToggles and game.save.objectToggles.VIRIDIAN_CITY
and game.save.objectToggles.VIRIDIAN_CITY.VIRIDIANCITY_OLD_MAN_SLEEPY == false
if gone then return false end
-- crossing north of his row through the 3-wide gap (x 17-19, y<=8)
if y <= 8 and x >= 17 and x <= 19 then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
or "You can't go\nthrough here!\fThis is private\nproperty!",
function() ow:scriptMove(ow.player, "down", 1) end))
return true
end
return false
if game.save.flags and game.save.flags.EVENT_GOT_POKEDEX then return false end
if x ~= 19 or y ~= 9 then return false end
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
or "You can't go\nthrough here!\fThis is private\nproperty!",
function() ow:scriptMove(ow.player, "down", 1) end))
return true
end,
}
@@ -104,7 +135,7 @@ M.BILLS_HOUSE = {
talk = {
TEXT_BILLSHOUSE_BILL_POKEMON = {
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 1
{ "jump_if_true", 11 }, -- 2
{ "jump_if_true", 13 }, -- 2
{ "show_text", "_BillsHouseBillImNotAPokemonText" }, -- 3
{ "show_text", "_BillsHouseBillNoYouGottaHelpText" }, -- 4
-- the cell-separator PC throws its switch
@@ -118,8 +149,21 @@ M.BILLS_HOUSE = {
{ "give_item", "S_S_TICKET", 1, false }, -- 7
{ "show_text", "_SSTicketReceivedText" }, -- 8
{ "set_flag", "EVENT_GOT_SS_TICKET" }, -- 9
{ "jump", 12 }, -- 10
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 11
-- The two Cerulean guards are a SWAP PAIR, not scenery
-- (BillsHouse.asm:174-178): handing over the ticket shows GUARD1 at
-- (28,12) and hides GUARD2 at (27,12). This matters far more than it
-- looks: (27,12) is the ONLY walkable neighbour of the trashed
-- house's south door at (27,11), and that house is one of the two
-- ways through the fence that splits Cerulean in half (the badge
-- house is the other). Leaving GUARD2 up forever severs the city --
-- the gym/mart half can never reach the Route 5 exit -- which is
-- exactly what stranded the bot after it beat Misty.
-- Same swap fires after the TM28 Rocket (CeruleanCity_2.asm
-- CeruleanHideRocket), so either route opens the path.
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 11
{ "jump", 14 }, -- 12
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 13
},
},
}
+43 -12
View File
@@ -278,6 +278,23 @@ M.PALLET_TOWN = {
local DRINKS = { "FRESH_WATER", "SODA_POP", "LEMONADE" }
-- Hand over the first drink in the bag, if any. Mirrors RemoveGuardDrink
-- (engine/items/inventory.asm), which walks the same three item ids and
-- removes ONE, and the caller's BIT_GAVE_SAFFRON_GUARDS_DRINK.
local function takeGuardDrink(game)
for _, drink in ipairs(DRINKS) do
if (game.save.inventory[drink] or 0) > 0 then
game.save.inventory[drink] = game.save.inventory[drink] - 1
if game.save.inventory[drink] == 0 then
game.save.inventory[drink] = nil
end
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
return true
end
end
return false
end
local function saffronGate(guardText, triggers, horizontal)
return {
talk = {
@@ -289,18 +306,11 @@ local function saffronGate(guardText, triggers, horizontal)
t._SaffronGateGuardThanksForTheDrinkText or "Gee, that was\ntasty!", done))
return
end
for _, drink in ipairs(DRINKS) do
if (game.save.inventory[drink] or 0) > 0 then
game.save.inventory[drink] = game.save.inventory[drink] - 1
if game.save.inventory[drink] == 0 then
game.save.inventory[drink] = nil
end
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or
"Thanks! You can\ngo on through!"), done))
return
end
if takeGuardDrink(game) then
game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or
"Thanks! You can\ngo on through!"), done))
return
end
game.stack:push(TextBox.new(game,
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!", done))
@@ -317,6 +327,27 @@ local function saffronGate(guardText, triggers, horizontal)
if game.save.flags.EVENT_GAVE_GUARDS_DRINK then return false end
local TextBox = require("src.render.TextBox")
local t = game.data.text
-- Stepping on the trigger WITH a drink hands it over right here.
--
-- Route5GateDefaultScript (scripts/Route5Gate.asm) runs
-- `farcall RemoveGuardDrink` before it decides anything: the coord
-- trigger itself takes the drink and sets
-- BIT_GAVE_SAFFRON_GUARDS_DRINK, and only a player carrying nothing
-- gets the thirsty line and the walk-back. We had the removal on the
-- guard's TALK handler only, so walking up with a FRESH_WATER in the
-- bag was turned away and the four gates stayed shut unless you
-- happened to talk to him -- which vanilla never requires.
--
-- Saffron is the middle of the map, so this sealed it: every route
-- through the city (Celadon <-> Lavender, Vermilion <-> Cerulean the
-- short way) was unreachable, and the bot could not get to Lavender
-- for the POKE_FLUTE at all.
if takeGuardDrink(game) then
game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or
"Thanks! You can\ngo on through!")))
return true
end
local back
if horizontal then
back = ow.player.facing == "left" and "right" or "left"
+27 -13
View File
@@ -63,7 +63,15 @@ M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
-- -------------------------------------------------------------------
-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at
-- (10,16) until identified with the Silph Scope and defeated.
-- (10,16) until defeated.
--
-- 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
-- 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).
-- -------------------------------------------------------------------
M.POKEMON_TOWER_6F = {
@@ -71,24 +79,30 @@ M.POKEMON_TOWER_6F = {
if game.save.flags.EVENT_BEAT_GHOST_MAROWAK then return false end
if x ~= 10 or y ~= 16 then return false end
local TextBox = require("src.render.TextBox")
if not game.save.inventory.SILPH_SCOPE then
game.stack:push(TextBox.new(game,
"A GHOST blocks\nthe way...\fDarn! You can't\nidentify it!",
function()
local back = ow.player.facing == "up" and "down" or "up"
ow:scriptMove(ow.player, back, 1)
end))
return true
end
local t = game.data.text
game.stack:push(TextBox.new(game,
"The GHOST was\nMAROWAK!\fThe restless soul\nattacks!", function()
t._PokemonTower6FBeGoneText or "Be gone...\nIntruders...", function()
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(game, "MAROWAK", 30)
if not game.save.inventory.SILPH_SCOPE then
battle:makeGhost()
end
battle.onFinish = function(result)
if result == "win" then
-- wBattleResult parity (PokemonTower6FMarowakBattleScript's
-- "and a / jr nz"): losing writes $1 and running writes $2, but
-- ItemUsePokeDoll ends the battle WITHOUT touching it, so the
-- script reads 0 -- defeated. That is the famous Poke Doll
-- trick, and the speedrun route this bot follows depends on it.
if result == "win" or battle.pokeDollEscape then
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true
game.stack:push(TextBox.new(game,
"The restless soul\ncalmed down and\ndeparted!"))
t._PokemonTower6FSoulWasCalmedText
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!"))
elseif result ~= "lose" then
-- .did_not_defeat: one simulated step right, off the trigger,
-- so fleeing does not leave you standing on a cell that
-- immediately re-fires.
ow:scriptMove(ow.player, "right", 1)
end
ow:afterBattle(result)
end
+10 -1
View File
@@ -360,7 +360,7 @@ local rocketRows = {
{ "jump_if_true", 9 }, -- 5
{ "show_text", "_CeruleanCityRocketText" }, -- 6
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
{ "jump_if_false", 16 }, -- 8
{ "jump_if_false", 18 }, -- 8
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
@@ -368,6 +368,15 @@ local rocketRows = {
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15
-- CeruleanHideRocket (scripts/CeruleanCity_2.asm) does all three behind
-- one fade: the ROCKET goes, GUARD1 (28,12) appears and GUARD2 (27,12)
-- disappears. GUARD2 stands on the only walkable neighbour of the
-- trashed house's south door, which is one of the two ways through the
-- fence dividing Cerulean, so this swap is what reconnects the city.
-- Bill's ticket performs the same swap (data/scripts/story.lua), and
-- both are idempotent, so whichever the player reaches first opens it.
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
}
M.CERULEAN_CITY = {
+44 -12
View File
@@ -13,6 +13,19 @@ local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
-- coroutine that receives `Game` and yields once per
-- frame; used headless (xvfb) for scripted screenshots
-- --speed N / POKEPORT_SPEED=N: run the logic clock N times faster without
-- touching audio (src/core/GameSpeed.lua). Overrides the saved option so a
-- bot or screenshot run is not at the mercy of the player's last choice.
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED"))
-- How many times to run a scripted act+step loop per rendered frame. Only
-- scripted runs use this; interactive play fast-forwards through
-- Game.speedOverride / the GAME SPEED option instead.
local function scriptedIterations()
if not (autopilot or driverCo) then return 1 end
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
end
local function bootGame()
Game = require("src.core.Game")
Game:load()
@@ -24,6 +37,10 @@ local function bootGame()
local fn = assert(loadfile(driverPath))()
driverCo = coroutine.create(fn)
end
-- After the two above are known: a scripted run drives the multiplier
-- from love.update's loop, so the in-engine one must stay at 1 or the
-- two would compound (10x10 = 100 steps per observation).
Game.speedOverride = (autopilot or driverCo) and 1 or speedOverride
end
function love.load(args)
@@ -33,6 +50,8 @@ function love.load(args)
editorMode = true
elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then
savePath = args[i + 1]
elseif a == "--speed" and tonumber(args[i + 1]) then
speedOverride = tonumber(args[i + 1])
end
end
love.graphics.setDefaultFilter("nearest", "nearest")
@@ -67,23 +86,36 @@ function love.update(dt)
if editorMode then return EditorApp.update(dt) end
if Importer then return Importer:update(dt) end
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
-- once per Game:update, so they must keep a 1:1 relationship with the
-- logic step. Fast-forwarding them by scaling the step inside
-- Game:update would run N steps per observation: a held direction walks
-- through all N, the player slides past the waypoint, and the script
-- re-plans from an overshot cell. So iterate the whole act+step loop
-- instead -- same script, just more of it per rendered frame.
local iterations = scriptedIterations()
if autopilot then
autopilot.update()
Game:update(1 / 60) -- deterministic stepping for the autopilot
for _ = 1, iterations do
autopilot.update()
Game:update(1 / 60) -- deterministic stepping for the autopilot
end
return
end
if driverCo then
local ok, err = coroutine.resume(driverCo, Game)
if not ok then
print("driver error: " .. tostring(err))
love.event.quit(1)
return
for _ = 1, iterations do
local ok, err = coroutine.resume(driverCo, Game)
if not ok then
print("driver error: " .. tostring(err))
love.event.quit(1)
return
end
if coroutine.status(driverCo) == "dead" then
love.event.quit()
return
end
Game:update(1 / 60)
end
if coroutine.status(driverCo) == "dead" then
love.event.quit()
return
end
Game:update(1 / 60)
return
end
Game:update(dt)
+11 -18
View File
@@ -78,23 +78,14 @@ run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
# ------- content tier: only meaningful with an imported ROM
# tests/run_tests.lua carries two pre-existing failures that are stale
# about the chip-audio architecture rather than real defects:
#
# Pikachu cry WAV exists nothing writes .wav any more -- cries are
# synthesized at play time from
# Data.audio.cries + programs.bin
# low-health alarm sfx extracted the importer deliberately does not
# extract it; Sound.startLoop falls back to
# ChipAudio.newLowHealthAlarm (Sound.lua:268)
#
# They are left in place (fixing them is a separate, reviewed change), so
# the tier passes on exactly this baseline and fails the moment a third
# failure appears or one of these two changes identity. Ignoring the exit
# code outright would hide every future content regression.
KNOWN_CONTENT_FAILURES=2
KNOWN_CONTENT_LINES="FAIL Pikachu cry WAV exists
FAIL low-health alarm sfx extracted"
# tests/run_tests.lua is expected to be clean. It used to carry two stale
# chip-audio assertions on the allowlist below (Pikachu cry WAV exists /
# low-health alarm sfx extracted); both have since been fixed, so the
# baseline is zero and any failure fails the tier. Keep the allowlist
# mechanism rather than ignoring the exit code -- that would hide every
# future content regression.
KNOWN_CONTENT_FAILURES=0
KNOWN_CONTENT_LINES=""
run_content_behavior() {
local out
@@ -107,7 +98,9 @@ run_content_behavior() {
if [ "$count" -eq "$KNOWN_CONTENT_FAILURES" ] \
&& [ "$lines" = "$(printf '%s\n' "$KNOWN_CONTENT_LINES" | sort)" ]; then
printf '%s\n' "$out" | tail -3
echo "(the $KNOWN_CONTENT_FAILURES known stale audio assertions, unchanged)"
if [ "$KNOWN_CONTENT_FAILURES" -gt 0 ]; then
echo "(the $KNOWN_CONTENT_FAILURES known stale assertions, unchanged)"
fi
return 0
fi
+23 -2
View File
@@ -2559,15 +2559,25 @@ function BattleState:learnMove(mon, moveId)
end
function BattleState:playerMonFainted()
if self.result then return end -- double faint: the battle is decided
local nextMon = Party.firstHealthy(self.game.save.party)
if not nextMon then
-- Being out of useable POKéMON blacks you out even when the battle was
-- already decided in our favour. A double faint -- our last mon dying
-- to residual damage on the turn it lands the KO -- used to hit the
-- "battle is decided" guard below and return with result = "win", so
-- afterBattle never took the lose branch: no revive, no warp to the
-- heal point, and the player was left standing on the map with a party
-- at 0 HP. Nothing recovers from that state (every later encounter
-- aborts with "no healthy party"), and it is not reachable in pokered:
-- HandlePlayerMonFainted runs the player-side check on its own, so
-- losing your last mon always blacks you out whatever the enemy did.
if not nextMon and self.result ~= "lose" then
self:sayNext(("%s is out of\nuseable POKéMON!"):format(self.game.save.player.name))
self:sayNext(("%s blacked\nout!"):format(self.game.save.player.name))
self.result = "lose"
self.afterQueue = "finish"
return
end
if self.result then return end -- double faint: the battle is decided
-- DoUseNextMonDialogue (core.asm:1052-1078): only WILD battles ask
-- "Use next POKéMON?"; NO goes through the run check with party slot
-- 1's speed, and a failed run still forces the party menu. Trainer
@@ -3026,6 +3036,17 @@ function BattleState:finish()
self.phase = "messages"
return
end
-- Invariant: a battle can never hand the overworld a party with nothing
-- healthy in it. afterBattle only revives and warps to the heal point on
-- "lose", so any other result here strands the player at 0 HP with no way
-- back -- an unrecoverable state, not merely a wrong one. playerMonFainted
-- is the path that should have caught this; if we land here it did not, so
-- say so rather than silently papering over it.
if self.result ~= "lose" and not Party.firstHealthy(self.game.save.party) then
Logger.warn("battle finished %s with no healthy party; forcing blackout",
tostring(self.result))
self.result = "lose"
end
self.lockedBall = nil
-- pokered never writes Mimic's copy into the party struct; leaving
-- battle discards the battle copy, so the original ids come back
+5 -1
View File
@@ -41,7 +41,11 @@ local CONSTANT_DEFAULTS = {
-- field.boot is the total-conversion override point for the new game; the
-- values match what SaveData.newGame and the Oak speech used to inline.
local BOOT_DEFAULTS = {
startMap = "PALLET_TOWN", startX = 5, startY = 6, startFacing = "down",
-- special_warps.asm NewGameWarp: REDS_HOUSE_2F, 3, 6 -- the bedroom, not
-- the tile outside the house. lastHeal is deliberately absent: SaveData
-- derives the vanilla blackout point, and seeding it here would leak into
-- total conversions that patch the spawn without naming a heal point.
startMap = "REDS_HOUSE_2F", startX = 3, startY = 6, startFacing = "down",
playerName = "RED", rivalName = "BLUE",
startMoney = 3000,
screens = { splash = "IntroMovie", title = "TitleState", newGame = "OakSpeech" },
+26 -2
View File
@@ -148,14 +148,38 @@ function Game:step(dt)
self.stack:update(dt)
-- play time for the trainer card / save screen
self.save.playTime = (self.save.playTime or 0) + dt
require("src.core.Music").update(Data)
-- Music.update is NOT serviced here: it decrements fade counters and
-- drives ChipAudio once per call, so running it inside the logic step
-- would pitch music and sfx up under fast-forward. Game:update advances
-- it on its own real-time 60Hz accumulator instead.
end
-- The logic multiplier for this frame. Read live rather than cached so the
-- Options row takes effect immediately; speedOverride is the --speed /
-- POKEPORT_SPEED run argument, which wins over the saved option so a bot
-- or screenshot run does not depend on whatever the player last chose.
function Game:logicSpeed()
local GameSpeed = require("src.core.GameSpeed")
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
local opts = self.save and self.save.options
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
end
function Game:update(dt)
-- Touch timers / prior-frame auto-releases before the fixed step so
-- deferred A and edge pulses land in Input's press queue for this step.
TouchInput:update(dt)
FixedStep:update(dt)
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
FixedStep:update(dt * self:logicSpeed())
-- Audio runs off real time at a fixed 60Hz regardless of game speed or
-- display refresh, so fades and chip synthesis keep their intended tempo
-- whether we are at 1X, 10X, or running with vsync disabled.
local step = FixedStep.STEP
self.audioAccum = math.min((self.audioAccum or 0) + dt, 0.25)
while self.audioAccum >= step do
self.audioAccum = self.audioAccum - step
require("src.core.Music").update(Data)
end
-- Overworld tilt toggle tween: presentational, so it runs on the real
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
require("src.render.Tilt").update(dt)
+54
View File
@@ -0,0 +1,54 @@
-- Fast-forward multiplier for game logic.
--
-- Speeding up means running the 1/60 fixed step N times per real frame
-- (Game:update), so everything driven by the step -- movement, text,
-- battle timing, scripts -- advances N times faster while staying
-- deterministic. Audio deliberately does NOT scale: Music.update drives
-- fade counters and ChipAudio synthesis off its own real-time 60Hz
-- accumulator in Game:update, so music and sfx play at normal pitch and
-- tempo at every speed.
--
-- Vsync still caps how much work a real frame can do, so 10X is a target
-- rather than a promise on a slow machine -- the logic simply runs as many
-- steps as the frame budget allows.
local GameSpeed = {}
-- 20X exists for the bot runs (tests/drivers/route.lua): a full-route
-- attempt is long enough that the iteration loop, not the engine, is the
-- bottleneck. Vsync caps how much a real frame can do, so past 10X the
-- multiplier is increasingly a ceiling rather than a rate.
GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75 }
GameSpeed.DEFAULT = 1
function GameSpeed.levelLabel(v)
v = tonumber(v) or GameSpeed.DEFAULT
if v == 1 then return "NORMAL" end
return tostring(v) .. "X"
end
-- nearest valid level for an arbitrary value (a hand-edited options.lua or
-- a --speed argument), so a bad number degrades to something sane
function GameSpeed.clamp(v)
v = tonumber(v)
if not v then return GameSpeed.DEFAULT end
local best, bestDiff = GameSpeed.DEFAULT, math.huge
for _, level in ipairs(GameSpeed.LEVELS) do
local diff = math.abs(level - v)
if diff < bestDiff then best, bestDiff = level, diff end
end
return best
end
-- cycle to the next/previous level, wrapping (the options row idiom)
function GameSpeed.cycle(v, dir)
local levels = GameSpeed.LEVELS
local cur = 1
for i, level in ipairs(levels) do
if level == GameSpeed.clamp(v) then cur = i break end
end
local nextIdx = (cur - 1 + (dir or 1)) % #levels + 1
return levels[nextIdx]
end
return GameSpeed
+33 -5
View File
@@ -41,6 +41,8 @@ function SaveData.defaultOptions()
musicVol = 7,
sfxVol = 7,
musicFilter = 0,
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
speed = 1,
-- port display options (OptionsMenu / hotkeys 2/3/5)
colors = "gbc",
tilt = 0,
@@ -516,8 +518,8 @@ end
local function scrubMaps(save, data, report)
local boot = (data.field and data.field.boot) or {}
local spawn = { map = boot.startMap or "PALLET_TOWN",
x = boot.startX or 5, y = boot.startY or 6 }
local spawn = { map = boot.startMap or "REDS_HOUSE_2F",
x = boot.startX or 3, y = boot.startY or 6 }
-- heal point first, so the player fallback below always lands somewhere
-- valid; boot's heal cell (threaded from field.boot) is the last resort
if save.lastHeal and not known(data.maps, save.lastHeal.map) then
@@ -627,11 +629,31 @@ end
-- boot is Data.field.boot, threaded in by Game: this module must not reach
-- into Data itself. Every read falls back to the Red literal it replaced,
-- so an absent or partial config still produces the vanilla new game.
-- Where blackouts and ESCAPE ROPE return to for a given boot config.
--
-- In vanilla this is NOT the spawn. wLastBlackoutMap is zero-filled at new
-- game and PALLET_TOWN is map 0, so the player starts in the bedroom
-- (special_warps.asm NewGameWarp) but blacks out to Pallet Town's fly_warp
-- cell (5, 6). A world that moves the spawn without naming a heal point
-- keeps the two together -- it may have no Pallet Town at all.
--
-- Shared with the Hall of Fame reset, which pokered writes as a literal
-- (HallOfFameResetEventsAndSaveScript: wLastBlackoutMap := PALLET_TOWN)
-- rather than deriving from the spawn.
function SaveData.defaultHeal(boot)
boot = type(boot) == "table" and boot or {}
local h = boot.lastHeal
if h then return { map = h.map, x = h.x, y = h.y } end
local map = boot.startMap or "REDS_HOUSE_2F"
if map == "REDS_HOUSE_2F" then return { map = "PALLET_TOWN", x = 5, y = 6 } end
return { map = map, x = boot.startX or 3, y = boot.startY or 6 }
end
function SaveData.newGame(boot)
boot = type(boot) == "table" and boot or {}
local map = boot.startMap or "PALLET_TOWN"
local x, y = boot.startX or 5, boot.startY or 6
local heal = boot.lastHeal or {}
local map = boot.startMap or "REDS_HOUSE_2F"
local x, y = boot.startX or 3, boot.startY or 6
local heal = SaveData.defaultHeal(boot)
local save = {
meta = { format = Version.saveFormat, mods = {} },
player = {
@@ -655,6 +677,12 @@ function SaveData.newGame(boot)
-- where blackouts and ESCAPE ROPE return to (updated by nurses);
-- copied, never aliased, so a save never writes back into Data
lastHeal = { map = heal.map or map, x = heal.x or x, y = heal.y or y },
-- Interiors inherit the SGB palette of the last outdoor map. wLastMap
-- is zero-filled at new game and PALLET_TOWN is map 0, so before the
-- player has ever been outdoors that palette is Pallet Town's -- which
-- matters because the vanilla spawn (REDS_HOUSE_2F) is itself indoors.
-- Without this the palette falls through to the ROUTE default.
lastOutdoor = { id = heal.map or map, x = heal.x or x, y = heal.y or y },
repelSteps = 0,
-- per-mod persistence (mod.save) lives under here, keyed by mod id
modData = {},
+4 -4
View File
@@ -523,11 +523,11 @@ function Commands.record_hall_of_fame(ctx)
-- save keeps the player standing in the HALL_OF_FAME room. (The
-- E4 room-script/event resets that precede the save in pokered are
-- the Indigo lobby's re-entry reset here, data/scripts/story6.lua.)
-- The reset heal point is field.boot's spawn, PALLET_TOWN (5,6)
-- in the vanilla dataset.
-- pokered writes PALLET_TOWN here as a literal, not as "the spawn" --
-- the vanilla spawn is REDS_HOUSE_2F. SaveData.defaultHeal carries
-- that split (and lets a total conversion redirect it).
local boot = game.data.field and game.data.field.boot or {}
ctx.save.lastHeal = { map = boot.startMap or "PALLET_TOWN",
x = boot.startX or 5, y = boot.startY or 6 }
ctx.save.lastHeal = require("src.core.SaveData").defaultHeal(boot)
if game.writeSave then game:writeSave() end
end)
end)
+6
View File
@@ -71,6 +71,12 @@ local function useOn(game, battle, id, target, list, moveIndex)
consume(game, id)
list:close()
showMessages(game, payload, function()
-- ItemUsePokeDoll sets wEscapedFromBattle and never touches
-- wBattleResult, so a script that reads the result afterwards sees
-- 0 -- "defeated". The ghost MAROWAK's script keys on exactly that
-- (the Poke Doll trick); the flag lets it tell this escape from an
-- ordinary RUN, which writes $2.
battle.pokeDollEscape = true
battle.result = "run"
battle.afterQueue = "finish"
battle.phase = "messages"
+12
View File
@@ -11,6 +11,7 @@
local PaletteFX = require("src.render.PaletteFX")
local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local GameSpeed = require("src.core.GameSpeed")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows")
@@ -190,6 +191,17 @@ local function buildRows(game)
GBCFX.setLevel(o.gbcfx)
return true
end },
-- fast-forward the logic clock only; music and sfx keep their tempo
-- (src/core/GameSpeed.lua), so this is safe to leave on
{ id = "speed", label = "GAME SPEED",
value = function(g)
return GameSpeed.levelLabel(g.save.options.speed)
end,
step = function(g, dir)
local o = g.save.options
o.speed = GameSpeed.cycle(o.speed, dir)
return true
end },
-- the manager's discoverable home (18-mod-manager-ux); inert until
-- opened, so the row costs a vanilla install nothing
{ id = "mods", label = "MODS",
+25 -3
View File
@@ -360,10 +360,15 @@ function OverworldState:paletteNameFor(map)
local palettes = FieldDefaults.field(Game.data, "palettes")
local name = map.def.palette or paletteLookup(palettes, map.id, map.def.tileset)
if not name then
-- interiors inherit the outdoor map they sit in; before the player has
-- been outdoors at all that is wherever the game starts
-- Interiors inherit the outdoor map they sit in. Before the player has
-- been outdoors at all, that is wLastMap's zero-fill -- map 0,
-- PALLET_TOWN -- and NOT the spawn: the vanilla spawn (REDS_HOUSE_2F)
-- is itself an interior and would fall through to the ROUTE default.
-- defaultHeal derives the same zero-fill map (wLastBlackoutMap shares
-- the reasoning) and lets a total conversion redirect it.
local boot = (Game.data.field and Game.data.field.boot) or {}
local last = self.lastOutdoor and self.lastOutdoor.id
or FieldDefaults.fieldValue(Game.data, "boot", "startMap")
or require("src.core.SaveData").defaultHeal(boot).map
local lastDef = last and Game.data.maps[last]
name = (last and paletteLookup(palettes, last, lastDef and lastDef.tileset))
or palettes.default
@@ -2275,8 +2280,19 @@ function OverworldState:onStepComplete()
-- arriving on a door/warp tile warp; a non-door warp square also fires
-- when the extra check passes and the d-pad is held
-- (CheckWarpsNoCollision)
-- The cell we warped in on is inert until we step off it: standing on it,
-- or being walked back onto it before leaving, does not re-fire (see
-- warpEntryCell where it is set). Once we are on any other cell it clears
-- and every warp is live again.
local entry = self.warpEntryCell
if entry and (p.cellX ~= entry.x or p.cellY ~= entry.y) then
self.warpEntryCell = nil
entry = nil
end
if self.justWarped then
self.justWarped = false
elseif entry then
-- still standing on the warp we arrived through; do not re-trigger it
else
local w = Warp.onArrive(self.map, p.cellX, p.cellY)
if not w and self:dirHeld() then
@@ -2777,6 +2793,12 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
Game.stack:push(Transition.new(Game, function()
self:setMap(mapId, x, y, facing or "down", opts)
self.justWarped = true
-- The warp we land ON stays inert until we physically step off it, so a
-- warp whose destination cell is itself a warp cannot bounce us straight
-- back (elevator cars, stacked stair/door mats). This generalizes the
-- one-step justWarped guard, which only skipped the very next frame's
-- check and so let a mon walked back onto the pad re-trigger it.
self.warpEntryCell = { x = x, y = y }
-- Fly/Teleport/Dig/Escape-Rope/blackout landings poof the player
-- back in (player_animations.asm EnterMapAnim); ordinary door
-- warps never take this branch
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12 -7
View File
@@ -112,8 +112,8 @@ check(#Data.constants.hmMoves == 5 and Data.constants.hmMoves[1] == "CUT",
"hmMoves seeded")
local boot = Data.field.boot
check(boot.startMap == "PALLET_TOWN" and boot.startX == 5 and boot.startY == 6
and boot.startFacing == "down", "field.boot seeded with the Pallet spawn")
check(boot.startMap == "REDS_HOUSE_2F" and boot.startX == 3 and boot.startY == 6
and boot.startFacing == "down", "field.boot seeded with the NewGameWarp spawn")
check(boot.playerName == "RED" and boot.rivalName == "BLUE"
and boot.startMoney == 3000, "field.boot seeded with the Red new-game values")
check(boot.namePresets.player[1] == "RED" and boot.namePresets.rival[1] == "BLUE",
@@ -312,16 +312,21 @@ check(#worldData.field.ledges == #Data.field.ledges,
-- ------- field.boot changes the new game
local vanillaSave = SaveData.newGame(Data.field.boot)
check(vanillaSave.player.map == "PALLET_TOWN" and vanillaSave.player.x == 5
-- special_warps.asm NewGameWarp is REDS_HOUSE_2F, 3, 6 -- the bedroom. This
-- previously asserted PALLET_TOWN (5, 6), which is where you stand after
-- walking out of the house, so a new game skipped Red's house entirely.
check(vanillaSave.player.map == "REDS_HOUSE_2F" and vanillaSave.player.x == 3
and vanillaSave.player.y == 6 and vanillaSave.player.facing == "down",
"the seeded boot config reproduces the Pallet spawn")
"the seeded boot config reproduces the NewGameWarp bedroom spawn")
check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE"
and vanillaSave.money == 3000, "the seeded boot config reproduces the Red start")
-- the heal point is deliberately NOT the spawn: wLastBlackoutMap is
-- zero-filled at new game and PALLET_TOWN is map 0
check(vanillaSave.lastHeal.map == "PALLET_TOWN" and vanillaSave.lastHeal.x == 5
and vanillaSave.lastHeal.y == 6, "heal point defaults to the spawn")
and vanillaSave.lastHeal.y == 6, "blackouts return to Pallet Town, not the spawn")
-- an absent config is still the vanilla new game
local bareSave = SaveData.newGame()
check(bareSave.player.map == "PALLET_TOWN" and bareSave.money == 3000
check(bareSave.player.map == "REDS_HOUSE_2F" and bareSave.money == 3000
and bareSave.lastHeal.map == "PALLET_TOWN",
"newGame without a boot config is unchanged")
@@ -355,7 +360,7 @@ check(bootData.field.boot.screens.title == "TitleState",
check(bootData.field.hiddenItems.CERULEAN_CAVE_1F ~= nil
and #bootData.field.flyOrder == #Data.field.flyOrder,
"sibling field keys are intact after a boot patch")
check(Data.field.boot.startMap == "PALLET_TOWN",
check(Data.field.boot.startMap == "REDS_HOUSE_2F",
"the boot merge never touched the live Data")
-- the save table is a copy: writing to it cannot reach back into Data
+3 -3
View File
@@ -205,7 +205,7 @@ end
local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
"musicVol", "sfxVol", "musicFilter", "colors", "tilt",
"gbcfx", "mods", "controls" }
"gbcfx", "speed", "mods", "controls" }
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
for i, id in ipairs(WANT_IDS) do
check(om.rows[i].id == id, "options row order: " .. id)
@@ -238,7 +238,7 @@ check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
-- the MODS row is the manager's discoverable home
local mgGame = optGame()
om = OptionsMenu.new(mgGame)
om.rows[11].activate(mgGame)
om.rows[12].activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState",
@@ -248,7 +248,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame()
om = OptionsMenu.new(cbGame)
om.rows[12].activate(cbGame)
om.rows[13].activate(cbGame)
local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list")
+89
View File
@@ -0,0 +1,89 @@
-- Parity test: a wild battle always returns to the action menu after a
-- POKé BALL fails to catch.
--
-- BattleState's message pump only leaves the "messages" phase through
-- afterQueue:
--
-- if self.phase == "messages" then
-- if not self:updateQueue() then
-- if self.afterQueue == "menu" then self.phase = "menu"
-- elseif self.afterQueue == "finish" then self:finish() end
-- end
-- return
-- end
--
-- so a drained queue with afterQueue set to anything else -- or to nothing
-- -- parks the battle in "messages" for good. Nothing on screen is
-- waiting, no input advances it, and the encounter can never end.
--
-- Found by the route driver, which reported it precisely once it was made
-- to give up rather than spin: "catch: livelocked on ROUTE_6 after 1201
-- iterations (steps=81, thrown=6, inBattle=true, phase=messages)". Six
-- balls thrown, battle still live, phase stuck. Before the guard it span
-- 10,229 times on one ODDISH and pinned the whole run.
--
-- Self-contained; run via `luajit tests/parity_ball_miss.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity ball miss")
local check, eq = S.check, S.eq
-- Drive the phase machine the way BattleState:update does, with a queue
-- that drains. The decision under test is what the phase becomes when the
-- queue empties, so the queue's contents do not matter -- only afterQueue.
local function pump(afterQueue, queued)
local b = {
phase = "messages",
afterQueue = afterQueue,
queue = queued or {},
finished = false,
}
b.updateQueue = function(self)
if #self.queue > 0 then table.remove(self.queue, 1) return true end
return false
end
b.finish = function(self) self.finished = true end
-- the real update()'s messages branch, verbatim
for _ = 1, 50 do
if b.phase == "messages" then
if not b:updateQueue() then
if b.afterQueue == "menu" then
b.phase = "menu"
elseif b.afterQueue == "finish" then
b:finish()
end
end
end
end
return b
end
-- The contract: "menu" and "finish" both leave the messages phase.
eq(pump("menu").phase, "menu", "afterQueue=menu returns to the action menu")
check(pump("finish").finished, "afterQueue=finish ends the battle")
-- ...and anything else is the livelock. This is the assertion that would
-- have caught it: a drained queue with no afterQueue never leaves
-- "messages", so the battle is unreachable by any input.
local stuck = pump(nil, { {}, {}, {} })
eq(stuck.phase, "messages", "afterQueue=nil is the stuck state (documented)")
check(#stuck.queue == 0, "the queue really did drain -- nothing is pending")
-- The real thing: openItems -> throwBall -> miss must leave afterQueue as
-- "menu" the whole way through, since throwBall itself never sets it.
local BattleState = require("src.battle.BattleState")
local openItems = BattleState.openItems
local fake = {
queue = {},
say = function(self, t) table.insert(self.queue, { text = t }) end,
act = function(self, f) table.insert(self.queue, { fn = f }) end,
ui = function(self, f) table.insert(self.queue, { ui = f }) end,
buildScreen = function() return {} end,
}
openItems(fake)
eq(fake.phase, "messages", "openItems parks the battle in the messages phase")
eq(fake.afterQueue, "menu", "openItems arms afterQueue=menu before the bag")
check(#fake.queue == 1 and fake.queue[1].ui ~= nil,
"openItems queues the bag as a ui item")
S.finish()
+109
View File
@@ -0,0 +1,109 @@
-- Parity test: losing your last POKéMON always blacks you out, even when
-- the battle was already decided in your favour.
--
-- A double faint -- the lead dying to residual damage on the same turn it
-- lands the KO -- used to leave BattleState:playerMonFainted at its
-- "the battle is decided" guard, so it returned with result = "win" still
-- set. OverworldState:afterBattle only revives the party and warps to the
-- heal point on "lose" (src/world/OverworldController.lua), so the player
-- was handed back to the overworld standing on the map with every POKéMON
-- at 0 HP. Nothing recovers from that: BattleState refuses to start an
-- encounter with no healthy party ("wild battle with no healthy party;
-- skipping"), so the save is bricked in place.
--
-- pokered cannot reach that state -- HandlePlayerMonFainted runs the
-- player-side check on its own account, so being out of useable POKéMON
-- blacks you out whatever happened to the enemy.
--
-- Found by the automated route driver: an attempt spent its whole length
-- "wiping" on VIRIDIAN_FOREST fourteen times without ever moving, because
-- the party was dead but the game had never blacked out.
-- Self-contained; run via `luajit tests/parity_double_faint.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("parity double faint")
local check, eq = S.check, S.eq
-- A stand-in carrying only what playerMonFainted touches. Deliberately not
-- a real battle: the point is the decision, and a full encounter would need
-- a loaded Data, an RNG and a queue to say nothing more than this does.
local function battleWith(partyHP, result)
local party = {}
for i, hp in ipairs(partyHP) do
party[i] = { species = "SQUIRTLE", hp = hp, stats = { hp = 20 } }
end
return {
kind = "wild",
result = result,
afterQueue = nil,
said = {},
-- the reserves-left path reads the "Use next POKéMON?" prompt off Data
data = { text = { _UseNextMonText = "Use next POKéMON?" } },
game = { save = { party = party, player = { name = "RED" } } },
sayNext = function(self, m) self.said[#self.said + 1] = m end,
say = function(self, m) self.said[#self.said + 1] = m end,
ui = function() end,
}
end
local function saidBlackout(b)
for _, m in ipairs(b.said) do
if tostring(m):find("blacked") then return true end
end
return false
end
-- The regression itself: a won battle whose last mon died with it.
do
local b = battleWith({ 0 }, "win")
BattleState.playerMonFainted(b)
eq(b.result, "lose", "a win with the last mon dead becomes a blackout")
eq(b.afterQueue, "finish", "the blackout ends the battle")
check(saidBlackout(b), "the blackout text still prints on a double faint")
end
-- Same for the other non-lose results, so no path can strand the party.
for _, result in ipairs({ "run", "caught" }) do
local b = battleWith({ 0 }, result)
BattleState.playerMonFainted(b)
eq(b.result, "lose", ("a %q result with no healthy party becomes a blackout")
:format(result))
end
-- An undecided battle keeps behaving exactly as before.
do
local b = battleWith({ 0 }, nil)
BattleState.playerMonFainted(b)
eq(b.result, "lose", "the ordinary last-mon faint still blacks out")
check(saidBlackout(b), "and still prints the blackout text")
end
-- The guard must not fire while something is still standing: a won battle
-- with a healthy reserve stays won, and the fainted-mon flow is untouched.
do
local b = battleWith({ 0, 15 }, "win")
BattleState.playerMonFainted(b)
eq(b.result, "win", "a win with a healthy reserve is still a win")
check(not saidBlackout(b), "and prints no blackout text")
end
-- Already lost: no second helping of the blackout text.
do
local b = battleWith({ 0 }, "lose")
BattleState.playerMonFainted(b)
eq(b.result, "lose", "an already-lost battle stays lost")
eq(#b.said, 0, "and does not re-announce the blackout")
end
-- A mid-battle faint with reserves left is the wild "Use next POKéMON?"
-- prompt (DoUseNextMonDialogue), not a blackout -- the path the guard
-- sits in front of, so prove it still runs.
do
local b = battleWith({ 0, 15 }, nil)
BattleState.playerMonFainted(b)
eq(b.result, nil, "a faint with reserves left does not decide the battle")
check(not saidBlackout(b), "and does not black out")
end
S.finish()
+161
View File
@@ -0,0 +1,161 @@
-- Parity test: the ghost MAROWAK battle opens with NO Silph Scope check
-- at the trigger, and the Poke Doll escape counts as defeating it.
--
-- PokemonTower6FDefaultScript (scripts/PokemonTower6F.asm) fires on
-- (10,16), shows _PokemonTower6FBeGoneText, and starts the RESTLESS SOUL
-- battle unconditionally -- the SILPH_SCOPE is only consulted inside the
-- battle (IsGhostBattle: disguised sprite, "too scared to move", balls
-- dodged). Our port used to turn the player back without the scope and
-- never open the battle, which made 6F impassable on any route that skips
-- Rocket Hideout -- including the speedrun route the bot follows, whose
-- answer to the MAROWAK is a POKE_DOLL, not the scope.
--
-- The doll works because of wBattleResult: the battle script's
-- "and a / jr nz .did_not_defeat" reads 0 as "defeated". Losing writes $1
-- and running writes $2, but ItemUsePokeDoll ends the battle without
-- touching it -- so the doll escape reads as a win and sets
-- EVENT_BEAT_GHOST_MAROWAK. BagMenu marks that escape as
-- battle.pokeDollEscape; the 6F script keys on it.
--
-- Self-contained; run via `luajit tests/parity_marowak.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity ghost marowak")
local check, eq = S.check, S.eq
-- Real TextBoxes want a Font atlas; the decision under test is which
-- branch runs, not how the text renders.
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end,
}
-- A recording stand-in for BattleState: newWild captures the species and
-- level, makeGhost sets the flag the real one sets, and the test drives
-- onFinish by hand.
local madeBattles = {}
package.loaded["src.battle.BattleState"] = {
newWild = function(_, species, level)
local b = { species = species, level = level, ghost = false }
b.makeGhost = function(self) self.ghost = true end
madeBattles[#madeBattles + 1] = b
return b
end,
}
local M = dofile("data/scripts/story3.lua")
local tower = M.POKEMON_TOWER_6F
check(tower ~= nil and tower.onStep ~= nil, "POKEMON_TOWER_6F has a step trigger")
local function gameWith(inventory, flags)
local pushed = {}
return {
save = { inventory = inventory or {}, flags = flags or {} },
data = { text = {} },
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}, pushed
end
local function owWith()
local moved, after = {}, {}
return {
player = { facing = "up" },
scriptMove = function(_, _, dir, n) moved[#moved + 1] = { dir, n } end,
afterBattle = function(_, result) after[#after + 1] = result end,
}, moved, after
end
-- Walk the trigger: run onStep, then the Be-gone text's done() to push
-- the battle. Returns the battle object (or nil).
local function trigger(game, pushed, ow, x, y)
local before = #madeBattles
local fired = tower.onStep(game, ow, x, y)
if not fired then return nil, fired end
local box = pushed[#pushed]
check(box ~= nil and box.done ~= nil, "the Be-gone text is pushed first")
box.done()
return madeBattles[#madeBattles], fired, #madeBattles > before
end
-- ---- 1. fires only on (10,16), only while the event is unset ------------
do
local game, pushed = gameWith()
local ow = owWith()
check(not tower.onStep(game, ow, 10, 15), "no trigger off the coord")
check(not tower.onStep(game, ow, 9, 16), "no trigger off the coord (x)")
eq(0, #pushed, "nothing pushed off the coord")
end
do
local game = gameWith({}, { EVENT_BEAT_GHOST_MAROWAK = true })
local ow = owWith()
check(not tower.onStep(game, ow, 10, 16),
"a departed MAROWAK never re-triggers")
end
-- ---- 2. NO scope: the battle still opens, disguised as a ghost ----------
do
local game, pushed = gameWith({})
local ow = owWith()
local battle, fired, created = trigger(game, pushed, ow, 10, 16)
check(fired, "trigger fires without the SILPH_SCOPE")
check(created and battle ~= nil,
"the battle OPENS without the scope (vanilla; the old port turned back)")
eq("MAROWAK", battle.species, "the opponent is the MAROWAK")
eq(30, battle.level, "at level 30")
check(battle.ghost, "and it is ghost-disguised without the scope")
end
-- ---- 3. scope: same battle, not disguised -------------------------------
do
local game, pushed = gameWith({ SILPH_SCOPE = 1 })
local ow = owWith()
local battle = trigger(game, pushed, ow, 10, 16)
check(battle ~= nil and not battle.ghost,
"with the scope the battle is not a ghost")
end
-- ---- 4. a win sets the event and shows the departed text ----------------
do
local game, pushed = gameWith({})
local ow, moved, after = owWith()
local battle = trigger(game, pushed, ow, 10, 16)
battle.onFinish("win")
check(game.save.flags.EVENT_BEAT_GHOST_MAROWAK, "win sets the event")
eq(0, #moved, "no shove after a win")
eq("win", after[#after], "afterBattle still runs")
end
-- ---- 5. the Poke Doll escape counts as a win (wBattleResult trick) ------
do
local game, pushed = gameWith({})
local ow, moved = owWith()
local battle = trigger(game, pushed, ow, 10, 16)
battle.pokeDollEscape = true
battle.onFinish("run")
check(game.save.flags.EVENT_BEAT_GHOST_MAROWAK,
"the doll escape sets EVENT_BEAT_GHOST_MAROWAK")
eq(0, #moved, "and does not shove the player")
end
-- ---- 6. an ordinary flee does NOT count, and steps you right ------------
do
local game, pushed = gameWith({})
local ow, moved = owWith()
local battle = trigger(game, pushed, ow, 10, 16)
battle.onFinish("run")
check(not game.save.flags.EVENT_BEAT_GHOST_MAROWAK,
"running away leaves the MAROWAK standing")
eq(1, #moved, "and walks the player off the trigger")
eq("right", moved[1] and moved[1][1], ".did_not_defeat steps RIGHT")
end
-- ---- 7. a loss neither sets the event nor shoves (blackout handles it) --
do
local game, pushed = gameWith({})
local ow, moved = owWith()
local battle = trigger(game, pushed, ow, 10, 16)
battle.onFinish("lose")
check(not game.save.flags.EVENT_BEAT_GHOST_MAROWAK, "a loss does not clear it")
eq(0, #moved, "no scripted step on a loss")
end
S.finish()
+112
View File
@@ -0,0 +1,112 @@
-- Parity test: mart inventories match pokered, and every item the route
-- driver tries to buy is actually on that mart's shelf.
--
-- Two failures in one, and the second is the one that bit.
--
-- Our extracted mart data was right all along -- VermilionMartClerkText is
-- POKE_BALL, SUPER_POTION, ICE_HEAL, AWAKENING, PARLYZ_HEAL, REPEL
-- (data/items/marts.asm:17), and that is exactly what we import. The route
-- driver's shopping list asked for plain POTION there, which Vermilion has
-- never sold. buyItem logged "shop: no POTION @ VERMILION_MART" into the
-- end-of-run summary and returned, so the bot left town with no healing at
-- all and then died nine times in Surge's gym and five more around
-- Cerulean, every fight taken at whatever HP the last nurse had left it.
--
-- A shopping list that names an unstocked item cannot work, and nothing
-- else in the run reports it loudly enough to notice. This asserts the
-- lists against the data instead.
--
-- Self-contained; run via `luajit tests/parity_mart_stock.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity mart stock")
local check, eq = S.check, S.eq
local T = dofile("data/generated/text_pointers.lua")
-- MAP_NAME -> text-pointer group ("CELADON_MART_4F" -> "CeladonMart4F")
local function groupOf(map)
local s = ""
for part in tostring(map):gmatch("[^_]+") do
if part:match("^%d") then
s = s .. part:upper()
else
s = s .. part:sub(1, 1):upper() .. part:sub(2):lower()
end
end
return s
end
-- every item sold on a map, across all its clerks
local function stockFor(map)
local group = T[groupOf(map)]
if not group then return nil end
local set, any = {}, false
for _, v in pairs(group) do
if type(v) == "table" and v.mart then
for _, id in ipairs(v.mart) do set[id] = true; any = true end
end
end
return any and set or nil
end
-- ---- 1. our extracted stock matches pokered's marts.asm -----------------
-- Spot-checks transcribed from data/items/marts.asm; the Vermilion row is
-- the one the driver got wrong, so it is pinned exactly.
local POKERED = {
VERMILION_MART = { "POKE_BALL", "SUPER_POTION", "ICE_HEAL", "AWAKENING",
"PARLYZ_HEAL", "REPEL" },
PEWTER_MART = { "POKE_BALL", "POTION", "ESCAPE_ROPE", "ANTIDOTE",
"BURN_HEAL", "AWAKENING", "PARLYZ_HEAL" },
LAVENDER_MART = { "GREAT_BALL", "SUPER_POTION", "REVIVE", "ESCAPE_ROPE",
"SUPER_REPEL", "ANTIDOTE", "BURN_HEAL", "ICE_HEAL",
"PARLYZ_HEAL" },
}
for map, want in pairs(POKERED) do
local sold = stockFor(map)
check(sold ~= nil, map .. " has mart data")
if sold then
for _, id in ipairs(want) do
check(sold[id], ("%s stocks %s (marts.asm)"):format(map, id))
end
end
end
-- and the absence that caused the bug
check(not (stockFor("VERMILION_MART") or {}).POTION,
"VERMILION_MART sells SUPER_POTION and NOT plain POTION")
-- ---- 2. every driver shopping list is actually stocked ------------------
-- Mirrors SHOP_STOCK in tests/drivers/route.lua. Kept as a literal rather
-- than reached into the driver, which needs a live Game to load.
local SHOP_STOCK = {
viridianBalls = { "POKE_BALL", "ANTIDOTE", "PARLYZ_HEAL" },
pewter = { "ESCAPE_ROPE", "POTION" },
vermilion = { "SUPER_POTION", "POKE_BALL" },
repels = { "SUPER_POTION" },
buffs = {},
pokeDoll = { "POKE_DOLL" },
tm07 = {}, vending = {}, water = {},
}
local R = dofile("tests/drivers/bot_route.lua")
local checked = 0
for i, seg in ipairs(R) do
for _, step in ipairs(seg.steps) do
if step.op == "shop" then
local list = SHOP_STOCK[step.list]
-- an unknown list name would silently fall back to POTION at runtime
check(list ~= nil,
("segment %d: shop list %q is known to the driver")
:format(i, tostring(step.list)))
local sold = stockFor(seg.map)
for _, id in ipairs(list or {}) do
checked = checked + 1
check(sold and sold[id],
("segment %d: %s sells %s"):format(i, seg.map, id))
end
end
end
end
check(checked > 0, "the route actually contains shop steps to check")
S.finish()
+120
View File
@@ -0,0 +1,120 @@
-- Parity test: the Saffron gate guards take the drink from the COORD
-- TRIGGER, not only when talked to.
--
-- Route5GateDefaultScript (scripts/Route5Gate.asm) runs
--
-- farcall RemoveGuardDrink
-- ldh a, [hItemToRemoveID]
-- and a
-- jr nz, .have_drink
--
-- before it decides anything, so simply stepping onto the trigger while
-- carrying FRESH_WATER / SODA_POP / LEMONADE hands it over and sets
-- BIT_GAVE_SAFFRON_GUARDS_DRINK. Only a player carrying none of the three
-- gets the "Gee, I'm thirsty" line and the walk-back.
--
-- Our port had the removal on the guard's talk handler alone, so walking up
-- with a drink in the bag was turned away and all four gates stayed shut
-- unless the player happened to talk to him -- which vanilla never asks
-- for. Saffron is the middle of the map, so this sealed the city: Celadon
-- <-> Lavender and the short Vermilion <-> Cerulean crossing both route
-- through it, and the route bot could not reach Lavender for the POKE_FLUTE
-- at all ("travelTo: no route to MR_FUJIS_HOUSE").
--
-- Self-contained; run via `luajit tests/parity_saffron_gate.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity saffron gate")
local check, eq = S.check, S.eq
-- The gate pushes real TextBoxes, which want a loaded Font atlas; the
-- decision under test is which branch runs, not how the text renders.
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end,
}
local M = dofile("data/scripts/story2.lua")
-- Enough of a game for the gate's onStep: an inventory, flags, a stack that
-- records what was pushed, and a player that records being shoved back.
local function gameWith(inventory)
local pushed, moved = {}, {}
return {
save = { inventory = inventory, flags = {} },
data = { text = {} },
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
_pushed = pushed,
_moved = moved,
}, pushed, moved
end
local function owWith(moved)
return {
player = { facing = "up" },
scriptMove = function(_, _, dir, n) moved[#moved + 1] = { dir, n } end,
}
end
-- ROUTE_5_GATE's trigger cells are (3,3) and (4,3) (.PlayerInCoordsArray).
local gate = M.ROUTE_5_GATE
check(gate ~= nil and gate.onStep ~= nil, "ROUTE_5_GATE has a coord trigger")
-- Carrying a drink: the trigger takes it and lets us through.
for _, drink in ipairs({ "FRESH_WATER", "SODA_POP", "LEMONADE" }) do
local game, pushed, moved = gameWith({ [drink] = 1 })
local ow = owWith(moved)
local handled = gate.onStep(game, ow, 3, 3)
check(handled, drink .. ": stepping on the trigger is handled")
check(game.save.flags.EVENT_GAVE_GUARDS_DRINK,
drink .. ": the guards are marked as having been given a drink")
eq(game.save.inventory[drink], nil, drink .. ": exactly one was removed")
check(#pushed == 1, drink .. ": the thanks text is shown")
check(#moved == 0, drink .. ": we are NOT walked back")
end
-- Only ONE drink is taken, even holding several.
do
local game = gameWith({ FRESH_WATER = 2, LEMONADE = 1 })
gate.onStep(game, owWith({}), 3, 3)
eq(game.save.inventory.FRESH_WATER, 1, "only one drink is consumed")
eq(game.save.inventory.LEMONADE, 1, "the other drinks are untouched")
end
-- Carrying nothing: thirsty line, and we get walked back the way we came.
do
local game, pushed, moved = gameWith({})
local ow = owWith(moved)
local handled = gate.onStep(game, ow, 3, 3)
check(handled, "no drink: the trigger still fires")
check(not game.save.flags.EVENT_GAVE_GUARDS_DRINK,
"no drink: the flag is NOT set")
check(#pushed == 1, "no drink: the thirsty text is shown")
-- the shove happens when the text box closes, not while it is up
if pushed[1] and pushed[1].done then pushed[1].done() end
check(#moved == 1 and moved[1][1] == "down",
"no drink: we are walked back the way we came")
end
-- Once given, the gate is open for good and stops triggering.
do
local game, pushed = gameWith({})
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
eq(gate.onStep(game, owWith({}), 3, 3), false,
"after the drink the trigger no longer blocks")
check(#pushed == 0, "and shows nothing")
end
-- Cells that are not trigger cells are ignored.
do
local game = gameWith({ FRESH_WATER = 1 })
eq(gate.onStep(game, owWith({}), 9, 9), false, "a non-trigger cell is ignored")
eq(game.save.inventory.FRESH_WATER, 1, "and takes no drink")
end
-- All four gates carry the same behaviour -- one drink opens every one.
for _, id in ipairs({ "ROUTE_5_GATE", "ROUTE_6_GATE",
"ROUTE_7_GATE", "ROUTE_8_GATE" }) do
check(M[id] and M[id].onStep, id .. " has the guard trigger")
end
S.finish()
+95
View File
@@ -0,0 +1,95 @@
-- Regression: a plan must never be blocked by another map's NPCs.
--
-- The route driver's BFS treats every entity as a wall, keyed by a folded
-- cell id (`y * width + x`). A warp swaps the map id before the entity list
-- is rebuilt, so a plan made in that window sees the PREVIOUS map's NPCs --
-- and folding hides how wrong that is. On an 8-wide gate, a forest NPC at
-- (16,43) folds to 43*8+16 = 360, which is a perfectly ordinary cell of the
-- gate; the wall lands somewhere innocent and nothing looks amiss.
--
-- Observed as: "goto (4,1) unreachable on VIRIDIAN_FOREST_NORTH_GATE; from
-- (2,1); npcs: SPRITE_YOUNGSTER@(16,43) SPRITE_YOUNGSTER@(30,33)
-- SPRITE_POKE_BALL@(12,29)" -- Viridian Forest coordinates listed against a
-- gate the size of a room. Every following segment skipped and the attempt
-- was lost.
--
-- The fix is to ignore any entity outside the current map's bounds. This
-- pins the folding arithmetic that makes the bug invisible, so a future
-- reader can see why the bounds check is not merely defensive.
--
-- Self-contained; run via `luajit tests/parity_stale_npc.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity stale npc")
local check, eq = S.check, S.eq
-- the driver's blocking rule, isolated: fold a cell, wall it, but only for
-- entities that are actually on this map
local function blockedCells(npcs, w, h)
local blocked = {}
for _, npc in ipairs(npcs) do
if npc.x >= 0 and npc.y >= 0 and npc.x < w and npc.y < h then
blocked[npc.y * w + npc.x] = true
end
end
return blocked
end
-- VIRIDIAN_FOREST_NORTH_GATE is 8 cells wide; the forest is far larger.
local GATE_W, GATE_H = 8, 8
-- The arithmetic that made this invisible: a foreign NPC folds onto a real
-- cell of the small map rather than into obvious nonsense.
eq(43 * GATE_W + 16, 360, "forest (16,43) folds onto a plain integer id")
eq(1 * GATE_W + 4, 12, "the gate's own (4,1) folds to 12")
-- Without the bounds check, foreign NPCs wall off cells of the gate.
do
local naive = {}
for _, n in ipairs({ { x = 16, y = 43 }, { x = 30, y = 33 }, { x = 12, y = 29 } }) do
naive[n.y * GATE_W + n.x] = true
end
check(next(naive) ~= nil,
"unguarded, off-map NPCs still produce blocked cell ids")
end
-- With it, they are ignored entirely.
do
local blocked = blockedCells({
{ x = 16, y = 43 }, { x = 30, y = 33 }, { x = 12, y = 29 },
{ x = 2, y = 18 }, { x = 27, y = 40 },
}, GATE_W, GATE_H)
check(next(blocked) == nil,
"every off-map NPC is ignored on the gate")
end
-- ...while the map's own NPCs still block, which is the whole point.
do
local blocked = blockedCells({
{ x = 3, y = 2 }, -- SPRITE_SUPER_NERD, really in the gate
{ x = 2, y = 5 }, -- SPRITE_GRAMPS
{ x = 16, y = 43 }, -- stale, from the forest
}, GATE_W, GATE_H)
check(blocked[2 * GATE_W + 3], "an NPC inside the gate still blocks its cell")
check(blocked[5 * GATE_W + 2], "and so does the second one")
local n = 0
for _ in pairs(blocked) do n = n + 1 end
eq(n, 2, "exactly the two real NPCs block -- the stale one does not")
end
-- Edge cases of the bound itself.
do
local blocked = blockedCells({
{ x = GATE_W - 1, y = GATE_H - 1 }, -- last legal cell
{ x = GATE_W, y = 0 }, -- one past the right edge
{ x = 0, y = GATE_H }, -- one past the bottom
{ x = -1, y = 0 }, -- negative
}, GATE_W, GATE_H)
check(blocked[(GATE_H - 1) * GATE_W + (GATE_W - 1)],
"the far corner is in bounds and blocks")
local n = 0
for _ in pairs(blocked) do n = n + 1 end
eq(n, 1, "off-by-one and negative coordinates are all rejected")
end
S.finish()
+190
View File
@@ -0,0 +1,190 @@
-- Parity test, Viridian City's two old men + the Pokédex object swap.
--
-- pokered has TWO old men on this map (data/maps/objects/ViridianCity.asm):
--
-- object_event 18, 9, SPRITE_GAMBLER_ASLEEP, STAY, NONE, ..._OLD_MAN_SLEEPY
-- object_event 17, 5, SPRITE_GAMBLER, WALK, LEFT_RIGHT, ..._OLD_MAN
--
-- The sleeper only ever grumbles "private property" and shoves you back
-- down; he never wakes, moves or hides. The coffee ask and the catch
-- tutorial belong to the walking man, who starts OFF
-- (data/maps/toggleable_objects.asm) and is swapped in for the sleeper
-- when Oak hands over the Pokédex (scripts/OaksLab.asm:602-606). The
-- north corridor is gated on EVENT_GOT_POKEDEX at exactly (19,9)
-- (ViridianCityCheckGotPokedexScript), not on either man's visibility.
--
-- All three of those were wrong at once: the port merged both men into
-- the sleeper, never ran the swap (so the walking man stayed hidden for
-- the entire game), and gated the corridor on the sleeper being hidden --
-- which made talking to him and answering "yes, I'm in a hurry" the only
-- way out of Viridian. Self-contained; run via `luajit tests/parity_viridian.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.VIRIDIAN_CITY) then Data:load() end
-- The gate builds a real TextBox, which wants a loaded Font atlas we have
-- no graphics device for. The hook requires it lazily, so a stub in
-- package.loaded is enough to exercise the branch headlessly -- we only
-- care that the step was blocked and that a box was pushed, not what it
-- rendered (parity_flavor already covers the text labels themselves).
package.loaded["src.render.TextBox"] = {
new = function(_, text, onDone) return { text = text, onDone = onDone } end,
}
local init = require("data.scripts.init")
local S = require("tests.harness").suite("parity viridian")
local check = S.check
local function rowsOf(script)
return type(script) == "table" and script or nil
end
-- find the first row whose command is `cmd`; returns index, row
local function findRow(rows, cmd, arg2)
for i, row in ipairs(rows or {}) do
if row[1] == cmd and (arg2 == nil or row[2] == arg2) then return i, row end
end
end
-- ---------------------------------------------------------------------
-- (1) the sleeper is text-and-shove only
-- ---------------------------------------------------------------------
local sleepy = rowsOf(init.talkScript("VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY"))
check(sleepy ~= nil, "sleeper resolves to a row-list script")
if sleepy then
check(findRow(sleepy, "show_text", "_ViridianCityOldManSleepyPrivatePropertyText") ~= nil,
"sleeper shows the private-property text")
check(findRow(sleepy, "move_player") ~= nil, "sleeper shoves the player back")
-- the bugs: his script must NOT own the other man's dialogue, and must
-- never hide himself (pokered's HideObject fires from Oak's lab instead)
check(findRow(sleepy, "ask") == nil, "sleeper does not ask about coffee")
check(findRow(sleepy, "old_man_demo") == nil, "sleeper does not run the catch demo")
check(findRow(sleepy, "hide_object") == nil, "sleeper never hides himself")
end
-- ---------------------------------------------------------------------
-- (2) the walking old man owns the coffee ask + the real catch tutorial
-- ---------------------------------------------------------------------
local oldMan = rowsOf(init.talkScript("VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_OLD_MAN"))
-- a Lua function handler here would mean data/scripts/flavor/viridian_city.lua
-- (which loads AFTER story.lua) had silently won the talk-table merge back
check(oldMan ~= nil, "walking old man resolves to a row-list script, not a handler")
if oldMan then
local askAt = findRow(oldMan, "ask", "_ViridianCityOldManHadMyCoffeeNowText")
check(askAt ~= nil, "walking old man asks the coffee question")
check(findRow(oldMan, "old_man_demo") ~= nil, "walking old man runs the catch demo")
-- polarity: the question is "Are you in a hurry?", so YES is the refusal
-- (ViridianCityOldManText: `and a / jr z, .refused` -- wCurrentMenuItem 0
-- is YES). jump_if_true must therefore land on the "Time is money" line.
local jAt, jRow = findRow(oldMan, "jump_if_true")
check(jAt ~= nil and askAt ~= nil and jAt > askAt, "the yes/no branch follows the ask")
if jRow then
local target = oldMan[jRow[2]]
check(target ~= nil and target[1] == "show_text"
and target[2] == "_ViridianCityOldManTimeIsMoneyText",
"YES (in a hurry) brushes you off rather than starting the demo")
end
-- pokered prints this AFTER the demo battle (EndCatchTrainingScript)
local demoAt = findRow(oldMan, "old_man_demo")
local weakenAt = findRow(oldMan, "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText")
check(demoAt and weakenAt and weakenAt > demoAt,
"the weaken-the-target line comes after the demo, not before it")
end
-- ---------------------------------------------------------------------
-- (3) the Pokédex performs the swap (OaksLab.asm:602-606)
-- ---------------------------------------------------------------------
local oak1 = rowsOf(init.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1"))
check(oak1 ~= nil, "Oak's main script resolves")
if oak1 then
local dexAt = findRow(oak1, "set_flag", "EVENT_GOT_POKEDEX")
check(dexAt ~= nil, "Oak sets EVENT_GOT_POKEDEX")
local hideAt = findRow(oak1, "hide_object", "VIRIDIAN_CITY")
local showAt = findRow(oak1, "show_object", "VIRIDIAN_CITY")
check(hideAt ~= nil, "the Pokédex hides the Viridian sleeper")
check(showAt ~= nil, "the Pokédex shows the walking Viridian old man")
if hideAt then
check(oak1[hideAt][3] == "VIRIDIANCITY_OLD_MAN_SLEEPY", "hides the right object")
end
if showAt then
check(oak1[showAt][3] == "VIRIDIANCITY_OLD_MAN", "shows the right object")
end
check(dexAt and hideAt and showAt and hideAt > dexAt and showAt > dexAt,
"the swap runs on the branch that grants the Pokédex")
end
-- ---------------------------------------------------------------------
-- (4) every jump target in the touched scripts is in range
--
-- Inserting the two swap rows renumbered Oak's whole 30-row jump table.
-- An off-by-one there is invisible until a branch silently runs the wrong
-- line, so check every target lands on a real row (or the end sentinel,
-- #rows + 1) across both files.
-- ---------------------------------------------------------------------
local JUMPS = { jump = true, jump_if_true = true, jump_if_false = true }
local checkedJumps = 0
for _, modname in ipairs({ "data.scripts.oaks_lab", "data.scripts.story" }) do
local mod = require(modname)
-- oaks_lab returns one map's table; story returns { [mapId] = table }
local maps = mod.talk and { [modname] = mod } or mod
for mapId, m in pairs(maps) do
if type(m) == "table" and m.talk then
for const, script in pairs(m.talk) do
local rows = rowsOf(script)
if rows then
for i, row in ipairs(rows) do
if type(row) == "table" and JUMPS[row[1]] then
local t = row[2]
checkedJumps = checkedJumps + 1
check(type(t) == "number" and t >= 1 and t <= #rows + 1,
("%s/%s row %d: %s -> %s in range"):format(
mapId, const, i, tostring(row[1]), tostring(t)))
end
end
end
end
end
end
end
check(checkedJumps > 0, "found jump rows to range-check (got " .. checkedJumps .. ")")
-- ---------------------------------------------------------------------
-- (5) the corridor gate keys off EVENT_GOT_POKEDEX at (19,9)
-- ---------------------------------------------------------------------
local onStep = init.get("VIRIDIAN_CITY").onStep
check(type(onStep) == "function", "VIRIDIAN_CITY has an onStep hook")
local function step(flags, x, y)
local pushed = 0
local game = {
save = { flags = flags, inventory = {}, objectToggles = {} },
data = Data,
stack = { push = function() pushed = pushed + 1 end },
}
local ow = { player = {}, scriptMove = function() end }
local ok, blocked = pcall(onStep, game, ow, x, y)
return ok, blocked, pushed
end
if type(onStep) == "function" then
local ok, blocked, pushed = step({}, 19, 9)
check(ok, "onStep runs at the gate cell")
check(ok and blocked == true, "(19,9) is blocked without the Pokédex")
check(ok and pushed == 1, "being blocked shows a text box")
local ok2, blocked2 = step({ EVENT_GOT_POKEDEX = true }, 19, 9)
check(ok2 and blocked2 ~= true, "(19,9) is walkable once you have the Pokédex")
-- the old port blocked the whole 3-wide corridor (x 17-19, y<=8); pokered
-- blocks one cell, and the sleeper/girl bodies do the rest
local ok3, blocked3 = step({}, 19, 8)
check(ok3 and blocked3 ~= true, "(19,8) north of the gate is not itself gated")
local ok4, blocked4 = step({}, 17, 8)
check(ok4 and blocked4 ~= true, "(17,8) is not gated (only (19,9) triggers)")
end
S.finish()
+16 -6
View File
@@ -1972,6 +1972,7 @@ do
local PaletteFX = require("src.render.PaletteFX")
local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local GameSpeed = require("src.core.GameSpeed")
local SD = require("src.core.SaveData")
-- Isolate from earlier save/options writes in this suite
SD.saveOptions(SD.defaultOptions())
@@ -2029,19 +2030,28 @@ do
for _ = 1, 4 do press("a") end
eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF")
press("down")
eq(om.index, 11, "cursor reaches MODS")
eq(om.index, 11, "cursor reaches GAME SPEED")
press("a")
eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X")
-- Driven by the level list rather than a literal press count: adding a
-- speed (20X went in for the bot runs) otherwise fails this as a wrap
-- bug when the cycling is fine and the row is simply one longer.
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL")
press("down")
eq(om.index, 12, "cursor reaches CONTROLS")
eq(om.index, 12, "cursor reaches MODS")
press("down")
eq(om.index, 13, "CANCEL stays the fixed final row")
eq(om.scroll, 8, "CANCEL keeps the last option boxes on screen")
eq(om.index, 13, "cursor reaches CONTROLS")
press("down")
eq(om.index, 14, "CANCEL stays the fixed final row")
eq(om.scroll, 9, "CANCEL keeps the last option boxes on screen")
om:draw() -- smoke: scrolled layout draws under the headless stub
press("a")
check(popped, "A on CANCEL closes the options menu")
local om2 = OptionsMenu.new(og)
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
eq(om2.index, 13, "up from the top wraps to CANCEL")
eq(om2.scroll, 8, "wrapping to CANCEL scrolls to the tail")
eq(om2.index, 14, "up from the top wraps to CANCEL")
eq(om2.scroll, 9, "wrapping to CANCEL scrolls to the tail")
-- headless-safe: no love.audio, setters only update internal state
require("src.core.Music").applyOptions(og.save.options)
require("src.core.Sound").applyOptions(og.save.options)
+196
View File
@@ -0,0 +1,196 @@
-- PokeBotBad route converter.
--
-- luajit tools/botconv/convert.lua <PokeBotBad checkout> [out.lua]
--
-- Reads PokeBotBad's data/red/paths.lua and emits a route data file for
-- the recomp: numeric pokered map ids become our string map names, tile
-- waypoints pass through unchanged (the coordinate systems are
-- identical), and every strategy/control step is classified by
-- table.lua into a generic op, a battle, a manual stub, or nothing.
--
-- The output is data, not code. tests/drivers/route.lua interprets it
-- against the live Game object. Anything table.lua does not cover is a
-- hard error -- the converter never silently drops a step.
local BOT = ... or arg[1]
assert(BOT, "usage: luajit tools/botconv/convert.lua <PokeBotBad checkout> [out]")
local OUT = arg[2] or "tests/drivers/bot_route.lua"
local TBL = dofile("tools/botconv/table.lua")
local mapOrder = dofile("data/generated/constants.lua").mapOrder
setmetatable(_G, { __index = function() return 0 end }) -- speedrun-only globals
local Paths = assert(loadfile(BOT .. "/data/red/paths.lua"))()
local stats = {
waypoint = 0, dropped = 0, battle = 0, verb = 0, manual = 0, control = 0,
}
local manualSeen, unknown = {}, {}
local function face(v) return TBL.face[v] or v end
-- Translate one {s=...} / {c=...} step into an op, or nil to drop it.
local function convertStep(step)
if step.s then
local name = step.s
if TBL.drop[name] then
stats.dropped = stats.dropped + 1
return nil
end
if TBL.battle[name] then
stats.battle = stats.battle + 1
return { op = "battle", face = step.dir and face(step.dir) or nil }
end
if TBL.manual[name] then
stats.manual = stats.manual + 1
manualSeen[name] = (manualSeen[name] or 0) + 1
return { op = "manual", name = name }
end
local v = TBL.verb[name]
if v then
stats.verb = stats.verb + 1
local out = { op = v.op }
for k, val in pairs(v.fixed or {}) do out[k] = val end
for botKey, ourKey in pairs(v.params or {}) do
local val = step[botKey]
if val ~= nil then
out[ourKey] = (ourKey == "face") and face(val) or val
end
end
return out
end
unknown[#unknown + 1] = "s=" .. name
return nil
end
local name = step.c
if TBL.controlDrop[name] then
stats.dropped = stats.dropped + 1
return nil
end
local cv = TBL.controlVerb[name]
if cv then
stats.control = stats.control + 1
local out = { op = cv.op }
if cv.mon ~= nil then out.mon = cv.mon end
return out
end
unknown[#unknown + 1] = "c=" .. name
return nil
end
-- ---------------------------------------------------------------------
-- PokeBotBad advances its path list sequentially whenever the map changes
-- (action/walk.lua:91-93) and only consults entry[1] when re-syncing after
-- a reset, so a wrong map id there never breaks its run -- and some are
-- wrong. Fix the ones we have verified rather than importing the typo:
-- entry 2 is labelled "Red's house" but carries 39 (BLUES_HOUSE); its
-- waypoints continue from Red's stairs, so it is REDS_HOUSE_1F (37).
local MAP_FIXUPS = { [2] = 37 }
local route = {}
for idx, entry in ipairs(Paths) do
local mapId = MAP_FIXUPS[idx] or entry[1]
local mapName = mapOrder[mapId + 1]
assert(mapName, ("unmapped pokered map id %d"):format(mapId))
local steps = {}
for j = 2, #entry do
local step = entry[j]
if step.s or step.c then
local op = convertStep(step)
if op then steps[#steps + 1] = op end
else
-- a bare {x,y} waypoint. Negative coords are the route's idiom for
-- "walk off the map edge into the connecting map"; the runtime
-- clamps into the connection rather than pathfinding to a cell that
-- does not exist.
stats.waypoint = stats.waypoint + 1
steps[#steps + 1] = { op = "goto", x = step[1], y = step[2] }
end
end
route[#route + 1] = { map = mapName, steps = steps }
end
if #unknown > 0 then
io.stderr:write("unclassified steps (add them to tools/botconv/table.lua):\n")
local seen = {}
for _, u in ipairs(unknown) do
if not seen[u] then seen[u] = true; io.stderr:write(" " .. u .. "\n") end
end
os.exit(1)
end
-- ---------------------------------------------------------------------
-- emit
-- ---------------------------------------------------------------------
-- Param values are scalars, or a list of candidate strings -- the route
-- writes poke={"oddish","paras"} for "teach this to whichever of these we
-- actually caught". Anything else is a bug in table.lua's param mapping
-- (an unquoted WRAM address, say) and must not reach the output as a
-- stringified pointer.
local function quote(v)
local t = type(v)
if t == "string" then return ("%q"):format(v) end
if t == "number" or t == "boolean" then return tostring(v) end
if t == "table" then
local parts = {}
for i, item in ipairs(v) do
assert(type(item) == "string",
("list param element %d is %s, expected string"):format(i, type(item)))
parts[i] = ("%q"):format(item)
end
assert(#parts > 0, "empty list param")
return "{ " .. table.concat(parts, ", ") .. " }"
end
error("unserializable param value of type " .. t)
end
local buf = {
"-- Generated by tools/botconv/convert.lua from PokeBotBad's any% route.",
"-- Do not edit; edit tools/botconv/table.lua and regenerate.",
"return {",
}
for _, seg in ipairs(route) do
buf[#buf + 1] = (" { map = %q, steps = {"):format(seg.map)
for _, s in ipairs(seg.steps) do
local parts = {}
local keys = {}
for k in pairs(s) do if k ~= "op" then keys[#keys + 1] = k end end
table.sort(keys)
for _, k in ipairs(keys) do
parts[#parts + 1] = ("%s = %s"):format(k, quote(s[k]))
end
buf[#buf + 1] = (" { op = %q%s },"):format(
s.op, #parts > 0 and (", " .. table.concat(parts, ", ")) or "")
end
buf[#buf + 1] = " } },"
end
buf[#buf + 1] = "}"
local fh = assert(io.open(OUT, "w"))
fh:write(table.concat(buf, "\n"), "\n")
fh:close()
-- ---------------------------------------------------------------------
-- coverage report
-- ---------------------------------------------------------------------
print(("wrote %s (%d map segments)"):format(OUT, #route))
print((" goto %4d"):format(stats.waypoint))
print((" battle %4d"):format(stats.battle))
print((" verb %4d"):format(stats.verb))
print((" control %4d"):format(stats.control))
print((" dropped %4d (speedrun / streaming only)"):format(stats.dropped))
print((" manual %4d <- runtime handlers required"):format(stats.manual))
local names = {}
for n in pairs(manualSeen) do names[#names + 1] = n end
table.sort(names)
print("\nhandlers tests/drivers/route.lua must implement:")
for _, n in ipairs(names) do
print((" %-20s x%d"):format(n, manualSeen[n]))
end
+123
View File
@@ -0,0 +1,123 @@
-- Inventory pass for the PokeBotBad route converter.
--
-- Reads PokeBotBad's data/red/paths.lua and reports every distinct step
-- shape it contains, so the conversion table is driven by what the route
-- actually uses rather than by guesswork.
--
-- luajit tools/botconv/inventory.lua /path/to/PokeBotBad
--
-- Route entries look like:
-- { <mapId>, {x,y}, {s="talk",dir="Up"}, {c="a",a="Brock's Gym"}, ... }
-- entry[1] is a numeric pokered map id; the rest are steps.
local botRoot = ... or arg[1]
assert(botRoot, "usage: luajit tools/botconv/inventory.lua <PokeBotBad checkout>")
-- paths.lua reads a few speedrun-only globals (client.speedmode targets).
-- They are dropped by the converter, so any value will do.
setmetatable(_G, { __index = function() return 0 end })
local pathsFile = botRoot .. "/data/red/paths.lua"
local Paths = assert(loadfile(pathsFile))()
local mapOrder = dofile("data/generated/constants.lua").mapOrder
local strategies, controls = {}, {}
local counts = { waypoint = 0, strategy = 0, control = 0, unknown = 0 }
local unknownMaps, sections = {}, {}
local function note(bucket, name, step, section)
local rec = bucket[name]
if not rec then
rec = { n = 0, params = {}, sections = {} }
bucket[name] = rec
end
rec.n = rec.n + 1
rec.sections[section] = true
for k in pairs(step) do
if k ~= "s" and k ~= "c" then rec.params[k] = true end
end
end
-- Section headers are comments, so recover them by scanning the source and
-- counting which route entry each header precedes.
local function sectionNames()
local names, idx, fh = {}, 0, assert(io.open(pathsFile, "r"))
local current = "0: INTRO"
for line in fh:lines() do
local header = line:match("^%-%-%s*(%d+:%s*.+)$")
if header then current = header end
if line:match("^%s*{%s*%-?%d+%s*,") then
idx = idx + 1
names[idx] = current
end
end
fh:close()
return names
end
local names = sectionNames()
for i, entry in ipairs(Paths) do
local mapId = entry[1]
local section = names[i] or "?"
sections[section] = true
local mapName = mapOrder[mapId + 1]
if not mapName then unknownMaps[mapId] = true end
for j = 2, #entry do
local step = entry[j]
if type(step) ~= "table" then
counts.unknown = counts.unknown + 1
elseif step.s then
counts.strategy = counts.strategy + 1
note(strategies, step.s, step, section)
elseif step.c then
counts.control = counts.control + 1
note(controls, step.c, step, section)
elseif type(step[1]) == "number" and type(step[2]) == "number" then
counts.waypoint = counts.waypoint + 1
else
counts.unknown = counts.unknown + 1
end
end
end
local function dump(title, bucket)
local keys = {}
for k in pairs(bucket) do keys[#keys + 1] = k end
table.sort(keys, function(a, b)
if bucket[a].n ~= bucket[b].n then return bucket[a].n > bucket[b].n end
return a < b
end)
print(("\n=== %s (%d distinct) ==="):format(title, #keys))
for _, k in ipairs(keys) do
local rec = bucket[k]
local params = {}
for p in pairs(rec.params) do params[#params + 1] = p end
table.sort(params)
local nsec = 0
for _ in pairs(rec.sections) do nsec = nsec + 1 end
print(("%-28s x%-4d sections:%-3d %s"):format(
k, rec.n, nsec,
#params > 0 and ("{" .. table.concat(params, ",") .. "}") or ""))
end
end
print(("route entries: %d sections: %d"):format(#Paths, (function()
local n = 0; for _ in pairs(sections) do n = n + 1 end; return n
end)()))
print(("waypoints:%d strategies:%d controls:%d unknown:%d"):format(
counts.waypoint, counts.strategy, counts.control, counts.unknown))
local bad = {}
for id in pairs(unknownMaps) do bad[#bad + 1] = id end
if #bad > 0 then
table.sort(bad)
print("UNMAPPED MAP IDS: " .. table.concat(bad, ", "))
else
print("all map ids resolve against data/generated/constants.lua mapOrder")
end
dump("STRATEGIES", strategies)
dump("CONTROLS", controls)
+187
View File
@@ -0,0 +1,187 @@
-- Classification table for the PokeBotBad route converter.
--
-- Every strategy/control name the route references maps to exactly one
-- bucket. convert.lua errors on anything missing, so this file is the
-- single place that decides what a PokeBotBad step becomes.
--
-- DROP speedrun or streaming only; no gameplay effect. Removing it
-- cannot make the run unwinnable, only slower.
-- BATTLE resolve the fight with the generic battle handler. We are not
-- optimizing turns, so every named fight collapses to one op.
-- VERB a generic parameterized action; params are rewritten below.
-- MANUAL progression-critical and not inferable from the route data.
-- Emitted as a stub op the runtime must implement, and listed in
-- the coverage report.
local T = {}
-- ---------------------------------------------------------------------
-- DROP
-- ---------------------------------------------------------------------
-- Timer splits, Twitch/LiveSplit bridge chatter, emulator speed control.
-- Stat-boost item pickups (carbos/rare candy) exist to hit damage
-- breakpoints on an optimal route; a bot that grinds normally outlevels
-- the need. "redbar" deliberately parks a mon at low HP for the Gen 1
-- low-HP speed trick and is actively harmful when not speedrunning.
-- "dodge*" routes around trainer sight lines to skip fights -- we let
-- those trainers engage and the generic battle handler takes them.
local DROP = {
"split", "splitBrock", "changeSpeed", "battleModeSet", "guess",
"tweetBrock", "tweetMisty", "tweetSurge", "tweetVictoryRoad",
"reportMtMoon", "announceMachop", "announceOddish", "announceVenonat",
"epicCutscene", "centerSkip", "jingleSkip",
"dodgeCerulean", "dodgePalletBoy", "dodgeDepartment", "dodgeGirl",
"dodgeViridianOldMan",
"redbarCubone", "redbarMankey",
"cinnabarCarbos", "safariCarbos", "silphCarbos",
"drivebyRareCandy", "rareCandyEarly", "rareCandyGiovanni",
"tossInSafari", "tossInVictoryRoad",
"swapXSpecials", "swapXSpeeds",
-- pre-emptive heals sized to an optimal route; the runtime's own
-- "heal when below threshold" rule supersedes all of them.
"potionBeforeMisty", "potionBeforeCocoons", "potionBeforeHypno",
"potionBeforeLorelei", "potionBeforeRaticate", "potionBeforeRocket",
"potionBeforeShorts", "potionBeforeSurge", "potionForMankey",
"extraFullRestore", "checkEther", "checkGiovanni",
-- turn-by-turn battle tactics, superseded by the generic battle AI
"thunderboltFirst", "fourTurnThrash", "thrashGeodude", "rivalSandAttack",
"swapThrash", "fightGiovanniMachoke", "fightSilphMachoke",
}
-- ---------------------------------------------------------------------
-- BATTLE
-- ---------------------------------------------------------------------
-- Named fights. Each becomes {op="battle"} -- walk in, fight until the
-- battle state pops. Rival/gym/E4 fights are all the same op; the route
-- has already put us in front of the right trainer.
local BATTLE = {
"fightBrock", "fightMisty", "fightSurge", "fightErika", "fightKoga",
"fightGiovanni", "fightSilphGiovanni", "fightBulbasaur", "fightMetapod",
"fightWeedle", "fightGrimer", "fightHypno", "fightX",
"lorelei", "bruno", "agatha", "lance", "blue", "champion",
"viridianRival", "lavenderRival", "silphRival",
"bugCatcher", "shortsKid", "digFight", "waitToFight",
"hornAttackCaterpie", "catchFlierBackup",
}
-- NOTE: squirtleIChooseYou is NOT here. Despite sitting between two fights
-- in the route it is the starter pick -- walk to the ball, press A, accept
-- the prompt -- and is classified as a talk below.
-- ---------------------------------------------------------------------
-- VERB
-- ---------------------------------------------------------------------
-- name -> { op, params = { botKey = ourKey } }
-- Params not listed are dropped. `face` values are rewritten from
-- PokeBotBad's "Up"/"Down"/"Left"/"Right" to our "up"/"down"/"left"/"right".
local VERB = {
talk = { op = "talk", params = { dir = "face" } },
waitToTalk = { op = "talk", params = { dir = "face" } },
interact = { op = "talk", params = { dir = "face" } },
dialogue = { op = "talk", params = { dir = "face", decline = "decline" } },
take = { op = "pickup", params = { dir = "face" } },
grabAntidote = { op = "pickup" },
grabForestPotion = { op = "pickup" },
grabMaxEther = { op = "pickup" },
grabTreePotion = { op = "pickup" },
bicycle = { op = "bike" },
procureBicycle = { op = "talk" },
-- the starter pick: face the ball, A, accept the prompt
squirtleIChooseYou = { op = "talk" },
fly = { op = "fly", params = { dest = "dest", map = "map" } },
push = { op = "push", params = { dir = "face", x = "x", y = "y" } },
teach = { op = "teach", params = { move = "move", poke = "mon", replace = "replace" } },
-- these two name the move rather than passing it as a param
teachThrash = { op = "teach", fixed = { move = "thrash" } },
learnThrash = { op = "teach", fixed = { move = "thrash" } },
swapMove = { op = "swapMove", params = { move = "move", to = "to" } },
swap = { op = "swapItem", params = { item = "item", dest = "dest" } },
item = { op = "useItem", params = { item = "item", poke = "mon", all = "all" } },
potion = { op = "heal", params = { hp = "hp", full = "full" } },
elixer = { op = "useItem", params = { move = "move" } },
ether = { op = "useItem", params = { max = "max" } },
hikerElixer = { op = "useItem" },
lassEther = { op = "useItem" },
undergroundElixer = { op = "useItem" },
healParalysis = { op = "heal" },
-- shops: the route knows the location, the runtime knows the list
shopPewterMart = { op = "shop", fixed = { list = "pewter" } },
shopViridianPokeballs = { op = "shop", fixed = { list = "viridianBalls" } },
shopVermilionMart = { op = "shop", fixed = { list = "vermilion" } },
shopRepels = { op = "shop", fixed = { list = "repels" } },
shopTM07 = { op = "shop", fixed = { list = "tm07" } },
shopPokeDoll = { op = "shop", fixed = { list = "pokeDoll" } },
shopVending = { op = "shop", fixed = { list = "vending" } },
shopExtraWater = { op = "shop", fixed = { list = "water" } },
shopBuffs = { op = "shop", fixed = { list = "buffs" } },
-- Not shops, despite the names. prepareForBlue/prepareForLance are
-- thin wrappers over strategyFunctions.potion (they fire in Lance's
-- and Agatha's rooms, where no mart exists). equipForBrock is a
-- level-8 reset gate plus a cure-poison, and the gate is speedrun-only
-- -- a bot that grinds normally arrives overlevelled.
prepareForBlue = { op = "heal", fixed = { full = true } },
prepareForLance = { op = "heal", fixed = { full = true } },
equipForBrock = { op = "heal", fixed = { status = true } },
-- "skill" is the route's field-move verb (cut/surf/strength/dig/flash)
skill = { op = "fieldMove", params = { move = "move", dir = "face",
x = "x", y = "y", map = "map" } },
}
-- ---------------------------------------------------------------------
-- MANUAL
-- ---------------------------------------------------------------------
-- Progression gates. A generic verb cannot infer these from route data:
-- they involve party composition, puzzle state, or menus the route only
-- names. Each emits {op="manual", name=...} and the runtime dispatches to
-- a hand-written handler keyed by name.
local MANUAL = {
"catchNidoran", "catchOddish", -- party composition the route assumes
"evolveNidoking", "evolveNidorino", -- level/stone gating
"trashcans", -- Surge gym can-search puzzle
"depositPokemon", -- PC box menu
"deptElevator", "silphElevator", -- elevator floor menus
"giveWater", -- Saffron guard gate
"playPokeFlute", -- Snorlax
"pokeDoll", -- Lavender Rocket blocker
"talkToBill", -- S.S. Ticket gate
"exitForest", -- Viridian Forest exit routing
"trainerSightSkip",
}
-- ---------------------------------------------------------------------
-- CONTROLS ({c="..."} steps)
-- ---------------------------------------------------------------------
-- Almost all of these are reset conditions or run telemetry. {c="a"} is
-- Bridge.chat -- Twitch commentary, 50 of the 98 control calls.
local CONTROL_DROP = {
"a", "encounters", "trackEncounters", "allowDeath", "pp", "thrash",
"moon1Exp", "moon2Exp", "moon3Exp", "startMtMoon",
"nidoranBackupExp", "viridianBackupExp", "viridianExp",
}
-- Catch permission toggles do affect what the bot ends up with.
local CONTROL_VERB = {
catchNidoran = { op = "allowCatch", mon = "nidoran" },
catchOddish = { op = "allowCatch", mon = "oddish" },
catchParas = { op = "allowCatch", mon = "paras" },
catchFlier = { op = "allowCatch", mon = "flier" },
disableCatch = { op = "allowCatch", mon = false },
potion = { op = "heal" },
}
-- ---------------------------------------------------------------------
local function set(list)
local t = {}
for _, name in ipairs(list) do t[name] = true end
return t
end
T.drop = set(DROP)
T.battle = set(BATTLE)
T.verb = VERB
T.manual = set(MANUAL)
T.controlDrop = set(CONTROL_DROP)
T.controlVerb = CONTROL_VERB
T.face = { Up = "up", Down = "down", Left = "left", Right = "right" }
return T