From 6fe106f55c28cf298d68684c17eb7c95fe7e8440 Mon Sep 17 00:00:00 2001 From: johnjohto Date: Tue, 4 Aug 2026 12:28:08 -0400 Subject: [PATCH 1/2] Stop offering the route Pokemon Centers as fly destinations --- src/ui/FlyMenu.lua | 9 ++- src/ui/TownMap.lua | 13 ++-- src/world/Map.lua | 18 ++++++ src/world/OverworldController.lua | 7 ++- tests/drivers/fly_indigo_bug203_test.lua | 4 +- tests/parity_fly_route_centers.lua | 77 ++++++++++++++++++++++++ 6 files changed, 114 insertions(+), 14 deletions(-) create mode 100644 tests/parity_fly_route_centers.lua diff --git a/src/ui/FlyMenu.lua b/src/ui/FlyMenu.lua index 3e512bb3..27d5a02b 100644 --- a/src/ui/FlyMenu.lua +++ b/src/ui/FlyMenu.lua @@ -12,12 +12,11 @@ function FlyMenu.new(game) local seen = {} for _, mapId in ipairs(game.data.field.flyOrder or {}) do -- towns only (dungeon escape spots share the table), each listed once. - -- Indigo Plateau (tileset PLATEAU) is a valid Fly destination too, so allow - -- it past the OVERWORLD-only isOutdoor gate while the CAVERN/FACILITY escape - -- spots stay excluded (LoadTownMap_Fly cycles it like any town, #203). + -- Map.isFlyTown is the BuildFlyLocationsList gate: map ids 0..10, so + -- INDIGO_PLATEAU cycles like any town (#203) while the ROUTE_4/ROUTE_10 + -- Pokemon Centers, fly warps but not towns, stay out (#788). local def = game.data.maps[mapId] - if visited[mapId] and def and not seen[mapId] - and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then + if visited[mapId] and def and not seen[mapId] and Map.isFlyTown(def) then seen[mapId] = true table.insert(items, { value = mapId, diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index 59048c56..32d52012 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -147,13 +147,14 @@ local function buildFlyList(game, byMap) for _, mapId in ipairs(field.flyOrder or {}) do local def = game.data.maps and game.data.maps[mapId] -- INDIGO_PLATEAU is a normal Fly spot (engine/menus/town_map.asm - -- LoadTownMap_Fly cycles it like any town), but its map uses tileset - -- "PLATEAU" not OVERWORLD, so Map.isOutdoor() alone dropped it from the - -- cursor even though it is visited and has a fly warp. Allow PLATEAU here - -- while the CAVERN/FACILITY dungeon escape spots that share flyOrder still - -- fail the gate and stay out (#203). + -- LoadTownMap_Fly cycles it like any town): its map id sits inside + -- BuildFlyLocationsList's 0..NUM_CITY_MAPS-1 walk, which is what + -- Map.isFlyTown checks, so it passes even though its tileset is + -- "PLATEAU" not OVERWORLD (#203). The ROUTE_4/ROUTE_10 Pokemon Centers + -- carry fly warps but are not towns, so they stay out (#788), as do the + -- CAVERN/FACILITY dungeon escape spots that share flyOrder. if not seen[mapId] and visited[mapId] and flyWarps[mapId] - and def and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then + and def and Map.isFlyTown(def) then seen[mapId] = true local loc = byMap[mapId] or { name = mapId:gsub("_", " ") } table.insert(flyLocs, loc) diff --git a/src/world/Map.lua b/src/world/Map.lua index 14b25b2c..c28ba8c8 100644 --- a/src/world/Map.lua +++ b/src/world/Map.lua @@ -24,6 +24,13 @@ local NO_SHORE_TILESETS = { SHIP_PORT = true } -- what counts as "outside" for the wLastMap memory (CheckIfInOutsideMap) local OUTSIDE_TILESETS = { "OVERWORLD", "PLATEAU" } +-- pokered's fly destination gate: BuildFlyLocationsList +-- (engine/items/town_map.asm) walks map ids 0..NUM_CITY_MAPS-1, the eleven +-- towns PALLET_TOWN..SAFFRON_CITY, so routes never appear even though +-- ROUTE_4/ROUTE_10 carry fly-warp landing spots (those exist for the +-- dungeon-escape/heal tables, special_warps.asm FlyWarpDataPtr) +local NUM_CITY_MAPS = 11 + -- warp pads and fall-through holes (data/tilesets/warp_pad_hole_tile_ids -- .asm WarpPadAndHoleData); a tileset record carrying warpPadTiles -- ({ [tileId] = "pad"|"hole" }) wins over these vanilla rows @@ -153,6 +160,17 @@ function Map.isOutside(def, tilesets) return false end +-- FLY destination (LoadTownMap_Fly / BuildFlyLocationsList): the eleven +-- towns, map indices 0..NUM_CITY_MAPS-1. ROUTE_4 and ROUTE_10 are outdoor +-- and have fly warps but are not towns, so the outdoor test alone offered +-- their Pokemon Centers as fly targets (#788). Maps without a vanilla +-- index (mod-authored) keep the old outdoor/PLATEAU surface test, which is +-- how a mod adds its own fly town. +function Map.isFlyTown(def) + if def.index ~= nil then return def.index < NUM_CITY_MAPS end + return Map.isOutdoor(def) or def.tileset == "PLATEAU" +end + -- region groups maps a rule applies to without naming them; the id prefix -- is the fallback for caches that predate the property function Map.inRegion(def, region, prefix) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index a0772860..85a7a5e1 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -364,7 +364,12 @@ function OverworldState:setMap(mapId, x, y, facing, opts) Game.save.flashLit = nil self:setDark(false) end - if Game.data.field.flyWarps[mapId] then + -- MarkTownVisitedAndLoadToggleableObjects marks towns only (cp + -- FIRST_ROUTE_MAP): the fly-warp table also carries the ROUTE_4/ROUTE_10 + -- Pokemon Centers and the dungeon escape spots, and entering those never + -- sets a wTownVisitedFlag bit, so it must not set save.visited either (#788) + local mapDef = Game.data.maps[mapId] + if Game.data.field.flyWarps[mapId] and mapDef and Map.isFlyTown(mapDef) then Game.save.visited = Game.save.visited or {} Game.save.visited[mapId] = true end diff --git a/tests/drivers/fly_indigo_bug203_test.lua b/tests/drivers/fly_indigo_bug203_test.lua index 3988227c..a9a02004 100644 --- a/tests/drivers/fly_indigo_bug203_test.lua +++ b/tests/drivers/fly_indigo_bug203_test.lua @@ -29,8 +29,8 @@ return function(game) } -- Exercise the real visited-marking path (OverworldController marks any - -- flyWarps map visited on entry) by standing on the Plateau exterior first, - -- then hop back to Pallet for a clean starting point. + -- flyWarps TOWN visited on entry, Map.isFlyTown) by standing on the Plateau + -- exterior first, then hop back to Pallet for a clean starting point. U.teleport(game, "INDIGO_PLATEAU", 9, 6, "down") U.wait(5) assert(game.save.visited.INDIGO_PLATEAU, diff --git a/tests/parity_fly_route_centers.lua b/tests/parity_fly_route_centers.lua new file mode 100644 index 00000000..6906190d --- /dev/null +++ b/tests/parity_fly_route_centers.lua @@ -0,0 +1,77 @@ +-- Parity test: the Route 4 / Route 10 Pokemon Centers are not FLY +-- destinations (#788). +-- +-- pokered keeps fly-warp landing spots for ROUTE_4 and ROUTE_10 in +-- data/maps/special_warps.asm FlyWarpDataPtr, but the fly picker never +-- offers them: BuildFlyLocationsList (engine/items/town_map.asm) walks map +-- ids 0..NUM_CITY_MAPS-1 only, and MarkTownVisitedAndLoadToggleableObjects +-- (engine/overworld/toggleable_objects.asm) sets a wTownVisitedFlag bit +-- only for maps below FIRST_ROUTE_MAP. The port walked the whole fly-warp +-- table and gated on "outdoor", so both route centers showed up as fly +-- targets the moment the player had set foot on those routes. +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.PALLET_TOWN) then Data:load() end + +local S = require("tests.harness").suite("parity fly route centers") +local check, eq = S.check, S.eq + +local Map = require("src.world.Map") +local FlyMenu = require("src.ui.FlyMenu") +local TownMap = require("src.ui.TownMap") + +local TOWNS = { + "PALLET_TOWN", "VIRIDIAN_CITY", "PEWTER_CITY", "CERULEAN_CITY", + "LAVENDER_TOWN", "VERMILION_CITY", "CELADON_CITY", "FUCHSIA_CITY", + "CINNABAR_ISLAND", "INDIGO_PLATEAU", "SAFFRON_CITY", +} + +-- ------------------------------------------------------------------ +-- 1) the gate itself, straight off generated map records +-- ------------------------------------------------------------------ +-- map indices 0..10 are exactly the eleven towns (data/generated/maps.lua +-- carries pokered's map constant order), and Map.isFlyTown keys off them +check(Map.isFlyTown(Data.maps.PALLET_TOWN), "PALLET_TOWN is a fly town") +check(Map.isFlyTown(Data.maps.INDIGO_PLATEAU), + "INDIGO_PLATEAU is a fly town (index 9, past the PLATEAU tileset)") +check(not Map.isFlyTown(Data.maps.ROUTE_4), "ROUTE_4 is not a fly town") +check(not Map.isFlyTown(Data.maps.ROUTE_10), "ROUTE_10 is not a fly town") +check(not Map.isFlyTown(Data.maps.POKEMON_MANSION_1F), + "POKEMON_MANSION_1F (dungeon escape spot) is not a fly town") +-- a mod-authored town has no vanilla index and keeps the outdoor test +check(Map.isFlyTown({ tileset = "OVERWORLD" }), + "an index-less outdoor map (mod town) is a fly town") +check(not Map.isFlyTown({ tileset = "CAVERN" }), + "an index-less cave map is not a fly town") + +-- ------------------------------------------------------------------ +-- 2) both pickers exclude the route centers, even on a polluted save +-- ------------------------------------------------------------------ +-- Saves written before this fix already carry visited.ROUTE_4 / +-- visited.ROUTE_10 (any map with a fly warp was marked on entry), so the +-- menu side has to filter, not just the writer. +local visited = { ROUTE_4 = true, ROUTE_10 = true } +for _, id in ipairs(TOWNS) do visited[id] = true end +local save = { visited = visited } + +local menu = FlyMenu.new({ data = Data, save = save }) +local menuIds = {} +for _, item in ipairs(menu.items or {}) do menuIds[#menuIds + 1] = item.value end +eq(#menuIds, #TOWNS, "FLY lists exactly the eleven towns") +for i, id in ipairs(TOWNS) do + eq(menuIds[i], id, ("FLY entry %d is %s"):format(i, id)) +end + +local tm = TownMap.new({ data = Data, save = save }, { fly = true }) +check(tm.fly == true, "the town map opens in fly mode") +local listed = {} +for _, id in ipairs(tm.flyMapIds or {}) do listed[id] = true end +eq(#tm.flyMapIds, #TOWNS, "the fly town map cycles exactly the eleven towns") +check(not listed.ROUTE_4, "ROUTE_4 is not on the fly town map") +check(not listed.ROUTE_10, "ROUTE_10 is not on the fly town map") +for _, id in ipairs(TOWNS) do + check(listed[id], id .. " is on the fly town map") +end + +S.finish() From 3d3e42c6a39e0257f05fa39a304ea2bc68dde776 Mon Sep 17 00:00:00 2001 From: johnjohto Date: Tue, 4 Aug 2026 12:28:24 -0400 Subject: [PATCH 2/2] Fix the fly map cursor start and cycle directions --- src/ui/TownMap.lua | 16 +++-- tests/parity_fly_cursor_order.lua | 102 ++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 tests/parity_fly_cursor_order.lua diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index 32d52012..ec6f305c 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -221,8 +221,13 @@ function TownMap.new(game, opts) local mapId = game.overworld and game.overworld.map and game.overworld.map.id self.playerLoc = mapId and self.byMap[mapId] or nil self.sel = 1 - for i, loc in ipairs(self.locs) do - if loc == self.playerLoc then self.sel = i break end + -- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST + -- fly destination (PALLET_TOWN), never the player's current town (#795). + -- Only the plain viewer snaps the cursor to where the player stands. + if not self.fly then + for i, loc in ipairs(self.locs) do + if loc == self.playerLoc then self.sel = i break end + end end self.blink = 0 return self @@ -267,14 +272,17 @@ function TownMap:update(dt) if self.fly then -- LoadTownMap_Fly: Up/Down cycle the visited destinations, A flies there, -- B cancels (handled above). moveList walks self.locs, now the fly list. + -- Up steps FORWARD through the towns (.pressedUp does inc hl: PALLET -> + -- VIRIDIAN -> PEWTER -> ...), Down steps back and wraps to the last + -- visited town from the top; the port had the two swapped (#795). if input:wasPressed("a") then Sound.play(self.game.data, "Press_AB") local mapId = self.flyMapIds[self.sel] self.game.stack:pop() if mapId and self.onFly then self.onFly(mapId) end return - elseif input:wasPressed("up") then self:moveList(-1) - elseif input:wasPressed("down") then self:moveList(1) + elseif input:wasPressed("up") then self:moveList(1) + elseif input:wasPressed("down") then self:moveList(-1) end elseif self.nestSpecies then if input:wasPressed("a") then diff --git a/tests/parity_fly_cursor_order.lua b/tests/parity_fly_cursor_order.lua new file mode 100644 index 00000000..1a1bb518 --- /dev/null +++ b/tests/parity_fly_cursor_order.lua @@ -0,0 +1,102 @@ +-- Parity test: the FLY town map opens on PALLET_TOWN and Up walks the +-- towns forward, Down backward (#795). +-- +-- pokered's LoadTownMap_Fly (engine/items/town_map.asm) enters its loop +-- with hl on wFlyLocationsList[0] -- the cursor ALWAYS starts on the first +-- fly destination, PALLET_TOWN, never the town the player is standing in. +-- .pressedUp does `inc hl` (next town, skipping NOT_VISITED entries, +-- wrapping at the $ff terminator back to the start) and .pressedDown does +-- `dec hl` (previous town, wrapping off the top to the last visited town). +-- The port started the cursor on the player's current town and had Up and +-- Down swapped, so the menu felt like it cycled in a random order. +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.PALLET_TOWN) then Data:load() end + +local S = require("tests.harness").suite("parity fly cursor order") +local check, eq = S.check, S.eq + +local TownMap = require("src.ui.TownMap") + +local function makeGame(visited, currentMap) + local pressed = {} + local game = { + data = Data, + save = { visited = visited }, + overworld = currentMap and { map = { id = currentMap } } or nil, + input = { wasPressed = function(_, name) + local p = pressed[name] + pressed[name] = nil + return p + end }, + stack = { pop = function() end }, + } + return game, function(name) pressed[name] = true end +end + +local function selection(tm) + return tm.flyMapIds[tm.sel] +end + +local ALL = { + PALLET_TOWN = true, VIRIDIAN_CITY = true, PEWTER_CITY = true, + CERULEAN_CITY = true, LAVENDER_TOWN = true, VERMILION_CITY = true, + CELADON_CITY = true, FUCHSIA_CITY = true, CINNABAR_ISLAND = true, + INDIGO_PLATEAU = true, SAFFRON_CITY = true, +} + +-- ------------------------------------------------------------------ +-- 1) the cursor opens on PALLET_TOWN, not the player's town +-- ------------------------------------------------------------------ +local game, tap = makeGame(ALL, "CELADON_CITY") +local tm = TownMap.new(game, { fly = true }) +check(tm.fly == true, "the town map opens in fly mode") +eq(selection(tm), "PALLET_TOWN", + "the fly cursor opens on PALLET_TOWN while the player stands in Celadon") + +-- ------------------------------------------------------------------ +-- 2) Up walks the towns forward, Down backward, both wrapping +-- ------------------------------------------------------------------ +tap("up") tm:update(0) +eq(selection(tm), "VIRIDIAN_CITY", "Up from PALLET_TOWN selects VIRIDIAN_CITY") +tap("up") tm:update(0) +eq(selection(tm), "PEWTER_CITY", "Up again selects PEWTER_CITY") +tap("down") tm:update(0) +eq(selection(tm), "VIRIDIAN_CITY", "Down steps back to VIRIDIAN_CITY") +tap("down") tm:update(0) +eq(selection(tm), "PALLET_TOWN", "Down again returns to PALLET_TOWN") +tap("down") tm:update(0) +eq(selection(tm), "SAFFRON_CITY", + "Down off the top wraps to the last visited town (SAFFRON_CITY)") +tap("up") tm:update(0) +eq(selection(tm), "PALLET_TOWN", "Up off the bottom wraps back to PALLET_TOWN") + +-- ------------------------------------------------------------------ +-- 3) unvisited towns are skipped, in list order +-- ------------------------------------------------------------------ +local PARTIAL = { PALLET_TOWN = true, VIRIDIAN_CITY = true, CELADON_CITY = true } +local game2, tap2 = makeGame(PARTIAL, "VIRIDIAN_CITY") +local tm2 = TownMap.new(game2, { fly = true }) +eq(selection(tm2), "PALLET_TOWN", + "the fly cursor opens on PALLET_TOWN on a partial save too") +tap2("up") tm2:update(0) +eq(selection(tm2), "VIRIDIAN_CITY", "Up selects VIRIDIAN_CITY") +tap2("up") tm2:update(0) +eq(selection(tm2), "CELADON_CITY", "Up skips every unvisited town to CELADON") +tap2("up") tm2:update(0) +eq(selection(tm2), "PALLET_TOWN", "Up off the end wraps to PALLET_TOWN") +tap2("down") tm2:update(0) +eq(selection(tm2), "CELADON_CITY", + "Down off the top wraps to CELADON, the last visited town") + +-- ------------------------------------------------------------------ +-- 4) the plain town map viewer still opens on the player's town +-- ------------------------------------------------------------------ +local game3 = makeGame(ALL, "CERULEAN_CITY") +local viewer = TownMap.new(game3) +check(not viewer.fly, "the plain viewer is not in fly mode") +check(viewer.locs[viewer.sel] == viewer.playerLoc, + "the plain viewer still opens on the player's current location") + +S.finish()