From 9468ffccdf99ad6293a2d66ca31ae8041a79e098 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 21 Jul 2026 09:55:03 -0400 Subject: [PATCH 1/5] solving more things and making the auto-player work almost perfectly --- data/scripts/story.lua | 56 +- data/scripts/story4.lua | 90 ++ src/world/FieldDefaults.lua | 6 + src/world/OverworldController.lua | 24 + tests/drivers/route.lua | 1637 +++++++++++++++++++++++++++-- tests/parity_I_M.lua | 19 +- tests/run_tests.lua | 3 + 7 files changed, 1771 insertions(+), 64 deletions(-) diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 0e63d611..9fde7de6 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -252,7 +252,17 @@ M.POKEMON_TOWER_7F = { { "show_object", "MR_FUJIS_HOUSE", "MRFUJISHOUSE_MR_FUJI" }, -- 5 { "hide_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET8" }, -- 6 { "show_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET9" }, -- 7 - { "warp", "MR_FUJIS_HOUSE", 3, 3, "down" }, -- 8 + -- pokered warps to wDestinationWarpID $1 (0-based) -- the house's + -- SECOND warp, the door mat at (3,7) -- facing UP + -- (PokemonTower7FWarpToMrFujiHouseScript: SPRITE_FACING_UP + + -- hWarpDestinationMap MR_FUJIS_HOUSE). Landing (3,3) instead put + -- the player at the Pokédex table, and the route's first waypoint + -- (3,7) then stepped onto a LIVE door mat and exited the house + -- before ever talking to Fuji -- so the POKE_FLUTE was never + -- collected and the Route 16 SNORLAX sealed the map. The arrival + -- mat itself is inert until stepped off (warpEntryCell), which is + -- what makes the vanilla coordinates safe. + { "warp", "MR_FUJIS_HOUSE", 3, 7, "up" }, -- 8 }, }, } @@ -387,6 +397,50 @@ M.WARDENS_HOUSE = { -- ------------------------------------------------------------------- M.SILPH_CO_11F = { + -- Giovanni's battle is a COORDINATE TRIGGER, not a talk. + -- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks + -- .PlayerCoordsArray -- (6,13) and (7,12) -- every frame while + -- EVENT_BEAT_SILPH_CO_GIOVANNI is unset: standing there shows his text, + -- walks him three tiles down (.GiovanniMovement), and starts the fight. + -- He also has no trainer-header entry, so sight engagement never fires + -- either. Without this hook he was a talk-only statue four tiles away + -- from anything the route (or a vanilla-faithful player walking the same + -- line) would touch, and the whole Silph ending -- the flag, the Master + -- Ball, the Saffron streets clearing -- silently never happened. + -- + -- engageTrainer shows TEXT_SILPHCO11F_GIOVANNI as the battle text and, + -- via victories.lua OPP_GIOVANNI#2, sets the event on a win; a loss + -- sets nothing, so the trigger re-arms exactly as vanilla does. + onStep = function(game, ow, x, y) + if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end + if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end + local gio + for _, npc in ipairs(ow.npcs) do + if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end + end + if not gio or ow:trainerDefeated(gio) then return false end + ow:scriptMove(gio, "down", 3, function() + gio:facePlayer(ow.player) + ow:engageTrainer(gio, function() + -- SilphCo11FTeamRocketLeavesScript: Giovanni leaves the floor + -- after the loss (the street rockets are handled by + -- M.SAFFRON_CITY.onEnter in story4.lua). + if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then + local Commands = require("src.script.Commands") + local ctx = { game = game, save = game.save, overworld = ow } + Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI") + end + end) + end) + return true + end, + onEnter = function(game, ow) + if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then + local Commands = require("src.script.Commands") + local ctx = { game = game, save = game.save, overworld = ow } + Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI") + end + end, talk = { TEXT_SILPHCO11F_SILPH_PRESIDENT = { { "face_player" }, -- 1 diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 6417e2e8..c5342a08 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -633,4 +633,94 @@ M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05, M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e, "_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM") +-- ------------------------------------------------------------------- +-- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4 +-- rooms this one gates its ENTRANCE, not its exit: the .blk ships with +-- the arena doorway CLOSED (blocks $72/$73 at block (2,6)/(3,6), cells +-- (4-7,12-13)), and LanceShowOrHideEntranceBlocks OPENS it ($31/$32) +-- on every map load while EVENT_LANCES_ROOM_LOCK_DOOR is unset. +-- Without this script the doorway never opened, so the whole arena -- +-- Lance AND both CHAMPIONS_ROOM warps at (5,0)/(6,0) -- was sealed off +-- from the entrance hall and the league dead-ended here ("goto (6,11) +-- unreachable on LANCES_ROOM"). +-- +-- LancesRoomDefaultScript's coordinate triggers, all inert once +-- EVENT_BEAT_LANCE is set: +-- (5,1)/(6,2) beside Lance -> his battle starts (a coordinate +-- trigger, not a talk; victories.lua OPP_LANCE#1 sets +-- EVENT_BEAT_LANCE on the win, so a loss re-arms) +-- (5,11)/(6,11) the doorway -> CheckAndSetEvent +-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals +-- the door behind the player with SFX_GO_INSIDE +-- (24,16) the entrance staircase -> WalkToLance: an auto-walk +-- (up 12, left 12, down 7, left 6) landing on (6,11). +-- It marches straight across the room's water decor: +-- pokered's CollisionCheckOnLand skips collision +-- entirely while simulated joypad states run, and our +-- scriptMove is collision-free the same way. +-- ------------------------------------------------------------------- + +local function lanceEntranceBlocks(game, ow) + local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR + ow:replaceBlock(2, 6, locked and 0x72 or 0x31) + ow:replaceBlock(3, 6, locked and 0x73 or 0x32) +end + +local function lanceLockDoor(game, ow) + if game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR then return end + game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR = true + require("src.core.Sound").play(game.data, "Go_Inside") + lanceEntranceBlocks(game, ow) +end + +local function lanceWalkIn(game, ow) + ow:scriptMove(ow.player, "up", 12, function() + ow:scriptMove(ow.player, "left", 12, function() + ow:scriptMove(ow.player, "down", 7, function() + ow:scriptMove(ow.player, "left", 6, function() + -- the walk lands on (6,11); vanilla's per-frame coord poll + -- then locks the door at once. scriptMove landings do not + -- fire onStep, so lock here. + lanceLockDoor(game, ow) + end) + end) + end) + end) +end + +M.LANCES_ROOM = { + onEnter = function(game, ow) + lanceEntranceBlocks(game, ow) + -- the warp arrival lands ON the staircase trigger, and onStep only + -- fires for completed steps -- start the walk-in here, the same + -- way Lorelei's auto walk-in runs from its onEnter + if not game.save.flags.EVENT_BEAT_LANCE + and ow.player.cellX == 24 and ow.player.cellY == 16 then + lanceWalkIn(game, ow) + end + end, + onStep = function(game, ow, x, y) + if game.save.flags.EVENT_BEAT_LANCE then return false end + if (x == 5 and y == 1) or (x == 6 and y == 2) then + local lance + for _, npc in ipairs(ow.npcs) do + if npc.def and npc.def.name == "LANCESROOM_LANCE" then lance = npc break end + end + if not lance or ow:trainerDefeated(lance) then return false end + lance:facePlayer(ow.player) + ow:engageTrainer(lance, function() end) + return true + end + if (x == 5 or x == 6) and y == 11 then + lanceLockDoor(game, ow) + return false + end + if x == 24 and y == 16 then + lanceWalkIn(game, ow) + return true + end + return false + end, +} + return M diff --git a/src/world/FieldDefaults.lua b/src/world/FieldDefaults.lua index b23f9774..e1b97338 100644 --- a/src/world/FieldDefaults.lua +++ b/src/world/FieldDefaults.lua @@ -92,6 +92,12 @@ local LAST_MAP_REWRITES = { UNDERGROUND_PATH_ROUTE_6 = { rules = { { map = "ROUTE_6" } } }, UNDERGROUND_PATH_ROUTE_7 = { rules = { { map = "ROUTE_7" } } }, UNDERGROUND_PATH_ROUTE_8 = { rules = { { map = "ROUTE_8" } } }, + -- DiglettsCaveRoute{2,11}_Script are the same pattern for the cave's two + -- entrance houses. Without these, crossing the cave and taking the far + -- house's LAST_MAP door warps you back to the route you ENTERED from -- + -- the whole cave was a one-way trip that always returned to its start. + DIGLETTS_CAVE_ROUTE_2 = { rules = { { map = "ROUTE_2" } } }, + DIGLETTS_CAVE_ROUTE_11 = { rules = { { map = "ROUTE_11" } } }, } FieldDefaults.FIELD = { diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 070ba7ea..d872ae90 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1506,6 +1506,21 @@ function OverworldState:trySurf(fx, fy) end function OverworldState:tryCut(fx, fy) + -- UsedCut (engine/overworld/cut.asm) gates on the TILESET before + -- anything else: only OVERWORLD (tree tile $3d) and GYM (plant tile + -- $50) have cuttable anything. Matching raw block ids alone + -- false-positives on every other tileset -- block ids are only + -- meaningful within one tileset, so Route 23 (PLATEAU) had blocks + -- matching a swap's `before`, and applying it wrote a block id that + -- does not exist in PLATEAU's block table: the renderer indexed nil + -- and the game crashed. The same false match is what made the bot + -- chain-cut "ornamental bushes" around Saffron and Celadon. + local ts = self.map.def.tileset + local tile = self.map:cellTile(fx, fy) + if not ((ts == "OVERWORLD" and tile == 0x3d) + or (ts == "GYM" and tile == 0x50)) then + return false + end local bx, by = math.floor(fx / 2), math.floor(fy / 2) local block = self.map:blockAt(bx, by) local swap @@ -1591,6 +1606,15 @@ function OverworldState:useCutFieldMove() if not self:partyKnows("CUT") then return "no_badge" end local fx, fy = self.player:facingCell() if not self.map:inBounds(fx, fy) then return "nothing" end + -- same tileset/tile gate as tryCut (UsedCut, engine/overworld/cut.asm): + -- a tree BLOCK also contains fence/path cells, and facing those is + -- "nothing to cut" in vanilla + local ts = self.map.def.tileset + local tile = self.map:cellTile(fx, fy) + if not ((ts == "OVERWORLD" and tile == 0x3d) + or (ts == "GYM" and tile == 0x50)) then + return "nothing" + end local bx, by = math.floor(fx / 2), math.floor(fy / 2) local block = self.map:blockAt(bx, by) local swap diff --git a/tests/drivers/route.lua b/tests/drivers/route.lua index a492a777..776da3b0 100644 --- a/tests/drivers/route.lua +++ b/tests/drivers/route.lua @@ -271,6 +271,27 @@ local function walk(dir, maxFrames) local p = ow().player if ow().map.id ~= smap then moved = true break end if (p.cellX ~= sx or p.cellY ~= sy) and not p.moving then moved = true break end + -- The step has COMMITTED (tween running toward a new cell): release + -- the key NOW and ride out the tween without it. Holding through the + -- landing frame attempts a further move the instant the tween ends, + -- and on an edge-collision warp cell that held press IS the warp + -- trigger (Warp.onCollision + extraCheck): walking DOWN onto Victory + -- Road 1F's entrance mat at (9,17) -- a waypoint the route walks + -- across horizontally, but our warp-averse BFS approaches from above + -- -- warped straight out to ROUTE_23, which is the "left + -- VICTORY_ROAD_1F partway through segment 183" exit. cellX commits + -- only at tween END (Player.lua), so committing is read off targetX. + if p.moving and p.targetX and (p.targetX ~= sx or p.targetY ~= sy) then + G.input.state[dir] = false + for _ = 1, 60 do + if ow().map.id ~= smap then break end + if not ow().player.moving then break end + coroutine.yield() + end + local q = ow().player + moved = ow().map.id ~= smap or q.cellX ~= sx or q.cellY ~= sy + break + end if busy() then -- A LEDGE HOP is performed with scriptMove, so it reads as "busy" -- exactly like a cutscene does -- and breaking here reported the hop @@ -361,6 +382,30 @@ local function passableCell(map, x, y) return p.surfing == true and map:isWaterCell(x, y) end +-- Tile-pair (elevation) collisions -- pokered's TilePairCollisionsLand / +-- Water, the same data src/world/Collision.lua enforces per step. CAVERN +-- and FOREST use these to fence off elevation shelves, so two adjacent +-- WALKABLE cells can still be uncrossable: Victory Road 1F's (5,9) -> +-- (5,8) is one, and a BFS that only checked walkability re-proposed that +-- refused step for walkOntoWarp's whole budget -- "warp@1,1 did not +-- move" x300 while travelTo ping-ponged ROUTE_23 <-> 1F. (Boulders are +-- exempt: checkBoulderPush never consults tile pairs, matching +-- push_boulder.asm, so only PLAYER movement models this.) +local function pairBlockedStep(map, sx, sy, tx, ty, surfing) + local tp = G.data.field and G.data.field.tilePairs + local list = tp and (surfing and tp.water or tp.land) + if not list or #list == 0 then return false end + local ts = map.def.tileset + local a, b = map:cellTile(sx, sy), map:cellTile(tx, ty) + for _, p in ipairs(list) do + if p.tileset == ts + and ((p.a == a and p.b == b) or (p.a == b and p.b == a)) then + return true + end + end + return false +end + local function bfsNextKey(tx, ty, extra) local o = ow() local map, p = o.map, o.player @@ -410,15 +455,19 @@ local function bfsNextKey(tx, ty, extra) -- route genuinely rides -- is untouched, and to elevator-destination -- warps so ordinary doors and stairs are untouched. The target cell -- is always allowed (never block where we are trying to go). + -- Never path THROUGH any warp cell -- stepping on one warps + -- (bug 10's warp parity), so a door mat on the walk line detours + -- the whole goto into a building. Saffron's segment 172 kept + -- diving into COPYCAT'S HOUSE exactly this way. The target cell is + -- always allowed: warps we MEAN to take are goto'd directly. local elevTrap = false - if tostring(map.id):find("CELADON_MART") and not (nx == tx and ny == ty) then - local wc = map.warpAtCell and map:warpAtCell(nx, ny) - local dm = wc and wc.def and wc.def.destMap - elevTrap = type(dm) == "string" and dm:find("ELEVATOR") ~= nil + if not (nx == tx and ny == ty) then + elevTrap = (map.warpAtCell and map:warpAtCell(nx, ny)) ~= nil end if nx >= 0 and ny >= 0 and nx < w and ny < map.heightCells and not prev[nid] and not blocked[nid] and not elevTrap - and (passableCell(map, nx, ny) or (nx == tx and ny == ty)) then + and (passableCell(map, nx, ny) or (nx == tx and ny == ty)) + and not pairBlockedStep(map, cx, cy, nx, ny, p.surfing) then prev[nid] = cur queue[#queue + 1] = nid else @@ -713,6 +762,17 @@ local LOW_HP_COVERED = 0.2 -- a nurse or a potion is within reach -- assigned below, once the healing helpers it needs exist local canRestoreHP = function() return false end +-- Forward-declared: defined with the travel helpers, needed by stepUpTo. +local reachableSet + +-- Forward-declared: defined with the party helpers; crossSeam and the +-- surf-mount logic need it before its definition point. +local slotKnowing + +-- Forward-declared: assigned after MANUAL.playPokeFlute exists. Wakes a +-- SNORLAX that is blocking the current map when the POKE FLUTE is aboard. +local wakeBlockingSnorlax + -- true while deliberately farming encounters: fleeing earns no XP, which is -- the whole point of grinding, so the flee guard stands down local grinding = false @@ -728,6 +788,13 @@ local healInBattle -- the static ghost MAROWAK, where fleeing leaves the blocker standing. local throwPokeDoll +-- Drive a MoveLearnMenu HM-safely wherever one appears. LEVEL-UP learns +-- open the same menu ops.teach handles, but they surface mid-battle and +-- in mashUntilIdle -- whose blind A lands on row 1, which after Silph is +-- SURF, and the engine's HM refusal loops forever. Defined after the +-- cursor helpers; forward-declared for fightBattle/mashUntilIdle. +local driveLearnMenu + local function lowOnHP(battle) if grinding then return false end -- training: take the XP, not the exit local mon = battle.player and battle.player.mon @@ -741,6 +808,10 @@ local function lowOnHP(battle) return mon.hp / max < (covered and LOW_HP_COVERED or LOW_HP_EXPOSED) end +-- the battle state seen hung on the last fightBattle timeout, for the +-- engine-hang bailout at the bottom of fightBattle +local hungBattle + local function fightBattle(limit) limit = limit or 20000 local frames = 0 @@ -881,13 +952,46 @@ local function fightBattle(limit) end press("a") else - press("a") -- intro / messages / anything else: advance + -- intro / messages / anything else: advance -- but never mash A + -- blind through a level-up MoveLearnMenu (the cursor sits on SURF) + if not (driveLearnMenu and driveLearnMenu()) then + press("a") + end end U.wait(3) frames = frames + 4 ::continue:: end - if frames >= limit then say("battle timed out") end + if frames >= limit then + say("battle timed out") + -- An engine hang: the battle stops responding to ANY input (seen + -- live vs Agatha -- GOLBAT sent out, an empty text frame, A/B dead, + -- while a clean A-mash of the same fight never reproduces it). + -- Every caller retries fightBattle, so a hung battle livelocks the + -- whole run: ops.goto_ alone re-enters it up to 600 times. On the + -- SECOND consecutive timeout of the SAME battle state, force the + -- state off the stack and let the overworld machinery re-engage + -- from scratch -- an unbeaten trainer is simply re-fought with + -- fresh RNG, a wild encounter is gone. Crude, but the alternative + -- is a dead run, and the route is built to recover from anything + -- the map throws at it. + if inBattle() then + local cur = G.stack:top() + if cur == hungBattle then + local foe = cur.enemy and cur.enemy.mon + say(("battle engine hang (phase=%s kind=%s foe=%s) -- forcing the " + .. "battle closed"):format(tostring(cur.phase), + tostring(cur.kind), tostring(foe and foe.species))) + hungBattle = nil + G.stack:pop() + U.wait(20) + else + hungBattle = cur + end + end + else + hungBattle = nil + end end -- --------------------------------------------------------------------- @@ -904,9 +1008,23 @@ local function mashUntilIdle(limit) if replacementMenu() then sendOutHealthy() end if inBattle() then fightBattle() end if idle() then return true end - press("a") + if driveLearnMenu and driveLearnMenu() then + frames = frames + 10 + else + press("a") + U.wait(6) + frames = frames + 7 + end + end + -- A alone cannot close everything: a wedged START/party/submenu chain + -- cycles forever under A (seen as 25 consecutive timeouts during the + -- Saffron bush-cutting spree). Unwind with B before reporting failure -- + -- B is safe everywhere mashUntilIdle is allowed (shops are B-only and + -- never come through here). + for _ = 1, 12 do + if idle() then return true end + press("b") U.wait(6) - frames = frames + 7 end say("mashUntilIdle timed out") return false @@ -960,6 +1078,27 @@ local travelDumped = false -- ops.fieldMove exists to do the cutting; declared here because ops.goto_ -- calls it and comes first. local cutToward +-- Open a Silph Co card-key door that is walling us off from (tx, ty). +-- Assigned next to cutToward; declared here for the same reason. +local openDoorToward +-- Press a reachable Pokémon Mansion statue switch when the switch walls +-- are what seals off (tx, ty). Assigned next to openDoorToward. +local toggleMansionSwitch +-- Bag-driven field item use / step-out-of-a-building helpers. Defined +-- with the item helpers far below; declared here because ops.fieldMove's +-- DIG fallback escape-ropes out of the Mansion. +local useFieldItem, leaveByDoor +-- Shove a STRENGTH boulder out of the walk to (tx, ty). Assigned next to +-- toggleMansionSwitch; declared here because ops.goto_'s ladder calls it. +local pushBoulderToward +-- Victory Road boulder-switch solver + its switch table; declared here +-- because walkOntoWarp's no-path ladder calls them. +local VR_SWITCHES, solveVictoryRoadSwitches +-- Mount SURF when land runs out. Defined after ops.fieldMove; used by +-- ops.goto_'s unreachable ladder so the Pallet -> Route 21 -> Cinnabar +-- water corridor is travellable (BFS is already water-aware WHILE +-- surfing -- the mount was the missing action). +local mountSurfToward -- Reach a warp cell by ARRIVING through it. Assigned below (it needs -- travelTo); declared here for the same reason as cutToward. @@ -969,6 +1108,32 @@ local arriveByWarp -- (needs walkOntoWarp); declared here because ops.goto_ calls it. local reEnterThroughGate +-- The border cell of `dir`'s edge that is WATER, nearest to (nearX, +-- nearY). Some seams are only crossable by sea: Cinnabar's north border +-- is land at x 10-13 with ocean beyond, so pushing off it just bumps the +-- shore -- the crossing is the water strip at x 1-3. +local function borderWaterCell(m, dir, nearX, nearY) + local best, bestD + if dir == "up" or dir == "down" then + local y = dir == "up" and 0 or m.heightCells - 1 + for x = 0, m.widthCells - 1 do + if m:isWaterCell(x, y) then + local d = math.abs(x - nearX) + if not bestD or d < bestD then best, bestD = { x, y }, d end + end + end + else + local x = dir == "left" and 0 or m.widthCells - 1 + for y = 0, m.heightCells - 1 do + if m:isWaterCell(x, y) then + local d = math.abs(y - nearY) + if not bestD or d < bestD then best, bestD = { x, y }, d end + end + end + end + return best +end + -- Walk to (x, y). Coordinates outside the map are the route's idiom for -- "leave through this edge into the connecting map", so we walk into the -- edge and let the connection carry us rather than pathfinding to a cell @@ -976,6 +1141,19 @@ local reEnterThroughGate function ops.goto_(s, _where, isLast) local o = ow() local map = o.map + -- one surf mount per goto: walking ashore dismounts, so an unreachable + -- target beyond a pond can otherwise mount -> cross -> dismount -> fail + -- -> mount again forever (Celadon's pond, target (39,8)) + local surfMounts = 0 + -- ...and a small cut budget per goto: a pocket behind two trees opens + -- over two passes, but a target that stays unreachable must not + -- chain-cut every ornamental bush on the map (Saffron/Celadon cut-spam) + local cuts, CUT_BUDGET = 0, 3 + -- one mansion-switch toggle per goto (see toggleMansionSwitch) + local switchToggles = 0 + -- boulder shoves per goto: a push chain along a row is several calls + -- (shove, re-plan, shove), so this is roomier than the cut budget + local boulderPushes = 0 local outside = s.x < 0 or s.y < 0 or s.x >= map.widthCells or s.y >= map.heightCells if outside then @@ -993,6 +1171,7 @@ function ops.goto_(s, _where, isLast) local tx = math.max(0, math.min(s.x, map.widthCells - 1)) local ty = math.max(0, math.min(s.y, map.heightCells - 1)) local refusedEdge = knownWalls(map.id) or {} + for edgeTry = 1, 2 do for _ = 1, 300 do if ow().map.id ~= startMap then return true end local p = ow().player @@ -1015,7 +1194,8 @@ function ops.goto_(s, _where, isLast) -- edge-exit branch is a separate path and never called it. local p2 = ow().player if p2.cellX ~= tx or p2.cellY ~= ty then - if cutToward(tx, ty) then + if cuts < CUT_BUDGET and cutToward(tx, ty) then + cuts = cuts + 1 goto edgeStep -- re-plan now that the way is open end -- The border can also be walled off by a gate rather than a @@ -1024,6 +1204,13 @@ function ops.goto_(s, _where, isLast) if reEnterThroughGate(tx, ty) then goto edgeStep end + -- ...or the border cell is on WATER (Pallet's south edge): + -- mount SURF at the shore and re-plan over the sea. + if surfMounts == 0 and mountSurfToward + and mountSurfToward(tx, ty) then + surfMounts = surfMounts + 1 + goto edgeStep + end end break -- already there, or genuinely no path end @@ -1060,6 +1247,24 @@ function ops.goto_(s, _where, isLast) end end end + -- The push stalled. Once per goto: if this edge has a WATER crossing + -- and the party can surf, mount and retry the whole exit over the + -- sea column instead of bumping the shore (Cinnabar -> Route 21). + if edgeTry == 1 and surfMounts == 0 and not ow().player.surfing + and slotKnowing("SURF") then + local wc = borderWaterCell(map, dir, tx, ty) + if wc and mountSurfToward and mountSurfToward(wc[1], wc[2]) then + surfMounts = surfMounts + 1 + tx, ty = wc[1], wc[2] + say(("edge exit %s on %s: retrying over the water at (%d,%d)") + :format(dir, tostring(startMap), tx, ty)) + else + break + end + else + break + end + end -- edgeTry say(("edge exit failed on %s: %s from (%d,%d) toward (%d,%d)") :format(tostring(startMap), dir, ow().player.cellX, ow().player.cellY, s.x, s.y)) @@ -1078,6 +1283,25 @@ function ops.goto_(s, _where, isLast) -- then the script walks us straight back and we oscillate for the full -- 600 iterations. Bail once a cell has come round too many times. local visits, oscillating = {}, 12 + -- Which cell each shove came from, so the plan can route AROUND the + -- trigger instead of merely giving up. A shove is a step that SUCCEEDS + -- and is then scripted back (Cinnabar's locked gym door at (18,4): + -- standing there without the SECRET_KEY prints "The door is locked..." + -- and walks you back down, and BFS -- deterministic, ties broken "up" + -- first -- re-proposed that exact step forever from (18,5)). Two + -- reverts on the same step target make it a refused cell for this trip; + -- per-call only, because the same cell is legitimately walkable later + -- (with the key in the bag the gate does not fire). + local reverts, lastFrom, lastTarget = {}, nil, nil + -- Whether the last successful step landed exactly ON the waypoint. A + -- SAME-MAP teleporter (Saffron Gym's warp pads) fires the moment that + -- step lands and moves us across the map, so the map-change guard never + -- returns and the loop hunts a waypoint it has already satisfied from + -- the far pocket -- which is how segment 173 wandered the gym, fought + -- two side trainers, and left without ever reaching Sabrina. A jump of + -- more than two cells distinguishes a pad from a gate-script shove + -- (which walks us back exactly one). + local steppedOnTarget = false for _ = 1, 600 do -- Stop the instant a warp changed the map under us. -- @@ -1094,6 +1318,14 @@ function ops.goto_(s, _where, isLast) -- must too, and the segment loop re-syncs to wherever the warp left us. if ow().map.id ~= map.id then return true end local p = ow().player + if steppedOnTarget then + local jump = math.abs(p.cellX - s.x) + math.abs(p.cellY - s.y) + if jump > 2 then + say(("goto (%d,%d): stepped on it and a pad moved us to (%d,%d) -- " + .. "waypoint done"):format(s.x, s.y, p.cellX, p.cellY)) + return true + end + end if p.cellX == s.x and p.cellY == s.y then -- Arrived. If the tween into the cell is still running, wait it out -- rather than falling through to BFS -- bfsNextKey returns nil when @@ -1154,6 +1386,18 @@ function ops.goto_(s, _where, isLast) -- cell -- not frames spent in a battle or waiting out a tween, which -- legitimately sit on one cell for a long time. local cell = p.cellY * ow().map.widthCells + p.cellX + -- Back where the last successful step started: that step was shoved. + if lastTarget and cell == lastFrom then + reverts[lastTarget] = (reverts[lastTarget] or 0) + 1 + if reverts[lastTarget] >= 2 and not refused[lastTarget] then + local W = ow().map.widthCells + say(("goto (%d,%d): the step onto (%d,%d) keeps getting shoved " + .. "back -- planning around it") + :format(s.x, s.y, lastTarget % W, math.floor(lastTarget / W))) + refused[lastTarget] = true + visits = {} -- the new plan deserves a fresh oscillation budget + end + end visits[cell] = (visits[cell] or 0) + 1 if visits[cell] > oscillating then say(("goto (%d,%d) gave up on %s: pathed away from (%d,%d) %d times " @@ -1193,7 +1437,30 @@ function ops.goto_(s, _where, isLast) -- a cutTreeSwaps entry. So beating Surge, healing at the centre -- and then running segment 71 found `goto (12,20)` unreachable, -- every following segment skipped, and a won gym was thrown away. - if cutToward(s.x, s.y) then + -- + -- Budgeted: on maps whose ornamental bushes match cutTreeSwaps + -- (Saffron, Celadon), a target that is walled off for some OTHER + -- reason otherwise chain-cuts every reachable bush before giving + -- up, and map re-entry regrows them all for the next pass. + if cuts < CUT_BUDGET and cutToward(s.x, s.y) then + cuts = cuts + 1 + blocked = 0 + goto keepGoing + end + -- ...or a Silph Co card-key door. The closed tile is not + -- walkable and only an A-press while facing it opens it + -- (OverworldState:tryCardKeyDoor), so to BFS every locked door + -- is a permanent wall -- which is how segment 148's walk to + -- Giovanni read "unreachable" and the Silph ending got skipped. + if openDoorToward(s.x, s.y) then + blocked = 0 + goto keepGoing + end + -- ...or a Pokémon Mansion gate in the wrong switch state. ONE + -- toggle per goto: if the flipped state is still wrong, the + -- next call flips it back rather than this one oscillating. + if switchToggles == 0 and toggleMansionSwitch(s.x, s.y) then + switchToggles = switchToggles + 1 blocked = 0 goto keepGoing end @@ -1213,6 +1480,24 @@ function ops.goto_(s, _where, isLast) blocked = 0 goto keepGoing end + -- ...or a sleeping SNORLAX is the wall and the POKE FLUTE is + -- aboard: wake it rather than report unreachable (the route's + -- own flute segment can get skipped in a desync). + if wakeBlockingSnorlax and wakeBlockingSnorlax("goto") then + blocked = 0 + goto keepGoing + end + -- ...or a STRENGTH boulder is the wall. PokeBotBad's Victory + -- Road waypoints path straight through boulders, shoving them + -- a cell per step -- so both "the target is under a boulder" + -- and "a boulder sits on the only path" land here. One shove, + -- then re-plan; budgeted like the cuts. + if boulderPushes < 8 and pushBoulderToward + and pushBoulderToward(s.x, s.y) then + boulderPushes = boulderPushes + 1 + blocked = 0 + goto keepGoing + end -- ...or the waypoint IS an NPC's own cell. PokeBotBad's -- collision model does not wall off NPCs, so its waypoints -- sometimes name the tile a clerk is standing on -- Celadon @@ -1242,6 +1527,18 @@ function ops.goto_(s, _where, isLast) break end end + -- ...or the land simply ends and the rest of the way is WATER. + -- Mount SURF at the nearest shore and re-plan; bfsNextKey + -- treats water as passable while surfing. Last in the ladder so + -- an NPC-blocked target in a pond town does not trigger a + -- pointless swim, and ONCE per goto -- coming ashore dismounts, + -- so a still-unreachable target re-mounts forever otherwise. + if surfMounts == 0 and mountSurfToward + and mountSurfToward(s.x, s.y) then + surfMounts = surfMounts + 1 + blocked = 0 + goto keepGoing + end say(("goto (%d,%d) unreachable on %s; from (%d,%d); npcs: %s") :format(s.x, s.y, ow().map.id, p.cellX, p.cellY, #who > 0 and table.concat(who, " ") or "none")) @@ -1249,6 +1546,15 @@ function ops.goto_(s, _where, isLast) end elseif walk(key) then blocked = 0 + -- remember the step for the shove detector above, target computed + -- from where we stood BEFORE the walk + local dv = DELTA[key] + if dv then + local W = ow().map.widthCells + lastFrom = cell + lastTarget = (math.floor(cell / W) + dv[2]) * W + (cell % W + dv[1]) + steppedOnTarget = lastTarget == s.y * W + s.x + end -- proved passable: forget any refusal an earlier run recorded here local now = ow() if now.map.id == map.id then @@ -1402,6 +1708,48 @@ end -- PokeBotBad's shop lists are exact counts tuned to a speedrun's money and -- damage breakpoints. We only need to stay alive and be able to catch +-- HM-safe MoveLearnMenu handling, shared by fightBattle, mashUntilIdle +-- and ops.teach's flow. Returns true if it did something (a learn menu is +-- somewhere on the stack), false when there is nothing learn-related up. +function driveLearnMenu() + local menu + for _, st in ipairs(G.stack.states or {}) do + if st.newMoveId and st.mon and st.index then menu = st break end + end + if not menu then return false end + local newId = tostring(menu.newMoveId or ""):upper() + local t = top() + if t == menu then + -- the forget list: pick the first move that is neither an HM field + -- move (the engine refuses, and they are the run's geography) nor + -- the move being learned + local UNFORGETTABLE = { SURF = true, CUT = true, STRENGTH = true, + FLY = true, FLASH = true, DIG = true } + local row + for ri, mv in ipairs((menu.mon and menu.mon.moves) or {}) do + local id = tostring(mv.id or mv):upper() + if not UNFORGETTABLE[id] and id ~= newId then row = ri break end + end + if not row then + press("b") -- nothing safe to forget: give up on the new move + U.wait(8) + return true + end + if cursorTo("index", row) then press("a") end + U.wait(10) + return true + end + if isChoice() then + cursorTo("index", 1) -- YES, delete an older move + press("a") + U.wait(8) + return true + end + press("a") -- the surrounding text boxes + U.wait(6) + return true +end + -- things, so each stop buys a plain stock instead. local SHOP_STOCK = { -- Viridian Mart stocks POKE_BALL, ANTIDOTE, PARLYZ_HEAL, BURN_HEAL and @@ -1452,6 +1800,20 @@ local SHOP_STOCK = { -- TM07 and the vending machine are speedrun tech (Horn Drill, drinks for -- the Saffron guards); the guards are handled by the giveWater handler tm07 = {}, vending = {}, water = {}, + -- Saffron Mart (GREAT_BALL, HYPER_POTION, MAX_REPEL, ESCAPE_ROPE, + -- FULL_HEAL, REVIVE -- marts.asm). Not a route stop; the segment loop + -- shops here when the heal-point anchor finds the bag out of HP items, + -- because Silph's 7F rival sits right above and a checkpoint-resumed + -- bag arrives empty -- five wipes' worth of empty. + saffronRestock = { { "HYPER_POTION", 3 }, { "MAX_REPEL", 3 }, + { "FULL_HEAL", 2 } }, + -- Cinnabar Mart (ULTRA_BALL, GREAT_BALL, HYPER_POTION, MAX_REPEL, + -- ESCAPE_ROPE, FULL_HEAL, REVIVE). The Mansion sits next door and its + -- B1F is chained wild fights: arriving with an empty bag drained the + -- lead to 2/182 and the blackout threw the arc away. The repels feed + -- the route's own useItem super_repel steps (segments 155/162). + cinnabarRestock = { { "HYPER_POTION", 4 }, { "MAX_REPEL", 3 }, + { "FULL_HEAL", 2 } }, -- The Indigo lobby mart (ULTRA_BALL, GREAT_BALL, FULL_RESTORE, -- MAX_POTION, FULL_HEAL, REVIVE, MAX_REPEL). Not a route stop -- the -- speedrun arrives provisioned -- but our bot arrives with whatever @@ -1484,6 +1846,80 @@ local function qtyTo(want, tries) return t ~= nil and t.qty == want end +-- Items we can always part with when the 20-slot bag is full. Money or +-- dead weight only: the NUGGET exists to be sold, TM_BIDE / TM_TOXIC / +-- TM_MEGA_DRAIN are never taught by the route, and status heals are +-- covered by FULL_HEALs. Nothing here gates progress. +local SELLABLE_JUNK = { "NUGGET", "TM_BIDE", "TM_TOXIC", "TM_MEGA_DRAIN", + "ANTIDOTE", "BURN_HEAL", "ICE_HEAL", "AWAKENING" } + +-- Sell the full stack of `id` from the shop's BUY/SELL/QUIT menu. Returns +-- with the menu back on top. +local function sellItem(id) + if not isMenu() then return false end + if not cursorTo("index", 2) then return false end -- SELL + press("a") + U.wait(10) + if not pressUntil(isList, "a", 30) then + press("b") + U.wait(6) + return false + end + local idx + for i, row in ipairs(rows()) do + if row.value == id then idx = i break end + end + local sold = false + if idx and cursorTo("index", idx) then + press("a") + U.wait(6) + if isQty() then + qtyTo(top().max or 1) -- the whole stack + press("a") + U.wait(6) + end + if isChoice() then + cursorTo("index", 1) -- YES + press("a") + U.wait(10) + end + pressUntil(isList, "a", 20) + sold = ((G.save.inventory or {})[id] or 0) == 0 + end + -- back out of the sell list to the BUY/SELL menu + for _ = 1, 10 do + if isMenu() then break end + press("b") + U.wait(6) + end + return sold +end + +-- Free up bag slots so `needed` NEW item kinds fit (Bag.CAPACITY is 20 +-- slots; a stack of an item already held costs nothing). This is why +-- every restock had been reporting "HYPER_POTION x0": the buy list +-- opened, the quantity was set, the engine said "no room" -- and the run +-- walked into the Mansion with FULL_HEALs but not one HP restore. +local function freeBagSlots(needed, where) + local used = #(G.save.bagOrder or {}) + local free = 20 - used + for _, id in ipairs(SELLABLE_JUNK) do + if free >= needed then break end + if ((G.save.inventory or {})[id] or 0) > 0 then + if sellItem(id) then + free = free + 1 + say(("shop: sold the %s to free a bag slot (%d free)") + :format(id, free)) + end + end + end + if free < needed then + say(("shop %s: bag still short %d slot(s) after selling junk") + :format(tostring(where), needed - free)) + end + return free +end + -- Buy `qty` of `id` from an open buy list. Leaves the list open. local function buyItem(id, qty, where) if not isList() then return false end @@ -1493,13 +1929,18 @@ local function buyItem(id, qty, where) end if not idx then note("shop: no " .. id, where) + say(("shop: %s is not on this buy list"):format(id)) + return false + end + if not cursorTo("index", idx) then + say(("shop: could not reach the %s row"):format(id)) return false end - if not cursorTo("index", idx) then return false end press("a") U.wait(6) if not isQty() then note("shop: no quantity box for " .. id, where) + say(("shop: no quantity box opened for %s"):format(id)) return false end -- QuantityBox caps at .max (what we can afford), so never ask for more @@ -1519,6 +1960,15 @@ local function buyItem(id, qty, where) end -- back to the list (the purchase text may need clearing first) pressUntil(isList, "a", 20) + -- Trust the BAG, not the menu flow: with all 20 slots used the engine + -- answers "You have no room for this!" through exactly the same + -- screens, and this function reported success while buying nothing -- + -- which is how every restock came back "HYPER_POTION x0" with no + -- failure line and the Mansion was fought without a single HP item. + if ((G.save.inventory or {})[id] or 0) < 1 then + say(("shop: %s purchase did not reach the bag (no room?)"):format(id)) + return false + end return true end @@ -1549,37 +1999,38 @@ end -- of why the party kept arriving at Brock with nothing to heal with. local function stepUpTo(ox, oy) local m = ow().map + local startMap = m.id + -- Candidates in preference order: beside the object, then TWO cells + -- away across a counter (mart clerks stand behind one -- Map's + -- counterTiles are talked across, same as the nurse's desk). + -- + -- Filtered by what we can ACTUALLY WALK TO, not just what is walkable. + -- Celadon Mart 4F is why: the clerk's own aisle (0-8,7) is sealed + -- behind the counter, so its cells are walkable-but-unreachable -- and + -- handing one to ops.goto_ fired its unreachable-recovery ladder + -- (arriveByWarp), which warped the bot clear off the floor hunting a + -- way in and left it "arrived" at the same coordinates on 1F, talking + -- to a wall. The shop then failed, no POKE_DOLL was bought, and the + -- ghost MAROWAK sealed the tower. + local reach, W = reachableSet() for _, d in ipairs({ { 1, 0, "left" }, { -1, 0, "right" }, - { 0, 1, "up" }, { 0, -1, "down" } }) do - local cx, cy = ox + d[1], oy + d[2] - if m:inBounds(cx, cy) and m:isWalkableCell(cx, cy) - and not ow():npcAtCell(cx, cy) then - local p = ow().player - if not (p.cellX == cx and p.cellY == cy) then - ops.goto_({ x = cx, y = cy }) - p = ow().player - end - if p.cellX == cx and p.cellY == cy then - faceDir(d[3]) - return true - end - end - end - -- No walkable cell touches the object: it stands BEHIND A COUNTER, the - -- normal case for every mart clerk (Celadon 2F/4F is where this bit -- - -- "FAILED at reaching the clerk" cost the run its SUPER_POTIONs and the - -- POKE_DOLL the MAROWAK strategy depends on). Counter tiles are talked - -- across (Map's counterTiles, same as the nurse's desk), so stand TWO - -- cells away with the counter between us and face the object. - for _, d in ipairs({ { 0, 2, "up" }, { 0, -2, "down" }, + { 0, 1, "up" }, { 0, -1, "down" }, + { 0, 2, "up" }, { 0, -2, "down" }, { 2, 0, "left" }, { -2, 0, "right" } }) do local cx, cy = ox + d[1], oy + d[2] - if m:inBounds(cx, cy) and m:isWalkableCell(cx, cy) then + if m:inBounds(cx, cy) and m:isWalkableCell(cx, cy) + and not ow():npcAtCell(cx, cy) + and reach[cy * W + cx] then local p = ow().player if not (p.cellX == cx and p.cellY == cy) then ops.goto_({ x = cx, y = cy }) p = ow().player end + if ow().map.id ~= startMap then + say(("stepUpTo (%d,%d): a warp moved us to %s -- giving up") + :format(ox, oy, tostring(ow().map.id))) + return false + end if p.cellX == cx and p.cellY == cy then faceDir(d[3]) return true @@ -1595,7 +2046,19 @@ function ops.shop(s, where) -- Stand next to the clerk first; the route's waypoint may not be -- adjacent to them (see stepUpTo). local clerk = findObject("CLERK") or findObject("CASHIER") - if clerk then stepUpTo(clerk.x, clerk.y) end + do + local p = ow().player + say(("shop %s: on %s at (%d,%d); clerk %s"):format( + tostring(s.list), tostring(ow().map.id), p.cellX, p.cellY, + clerk and ("@(%d,%d)"):format(clerk.x, clerk.y) or "NOT FOUND")) + end + if clerk then + local reached = stepUpTo(clerk.x, clerk.y) + local p = ow().player + say(("shop %s: stepUpTo %s; now on %s at (%d,%d) facing %s"):format( + tostring(s.list), reached and "ok" or "FAILED", + tostring(ow().map.id), p.cellX, p.cellY, tostring(p.facing))) + end -- Shops failed silently into `note` for a long time, and a note only -- surfaces in the end-of-run summary -- long after the empty bag has -- already cost a catch or a gym. Name the stage that failed, live. @@ -1621,6 +2084,20 @@ function ops.shop(s, where) mashUntilIdle() return false end + -- Make room BEFORE buying: count the list entries that would need a NEW + -- bag slot and sell junk until they fit (see freeBagSlots). + do + local newKinds = 0 + for _, want in ipairs(stock) do + if ((G.save.inventory or {})[want[1]] or 0) == 0 then + newKinds = newKinds + 1 + end + end + local used = #(G.save.bagOrder or {}) + if newKinds > 0 and 20 - used < newKinds then + freeBagSlots(newKinds, where) + end + end if not cursorTo("index", 1) then stageFailed("cursor to BUY") closeShop() @@ -1949,6 +2426,19 @@ end local function walkOntoWarp(wx, wy) local from = ow().map.id local stuckOnWarp = 0 + local bfsNil = 0 + local cuts = 0 + -- Shove learning, same as ops.goto_: a coordinate-trigger script that + -- walks the player back (Cinnabar's locked gym door at (18,4)) makes a + -- deterministic BFS re-propose the same shoved step for the whole loop + -- ("warp@6,3 did not move" x300, seen live from (18,5)). Refuse the + -- cell after two reverts and plan around; per-call, since the trigger + -- stops firing once its condition is met. + local refused, reverts, lastFrom, lastTarget = {}, {}, nil, nil + -- one mansion-switch toggle per call (see toggleMansionSwitch) + local toggledSwitch = false + -- one Victory Road switch solve per call (see solveVictoryRoadSwitches) + local solvedVR = false for _ = 1, 300 do if ow().map.id ~= from then return true end if inBattle() then fightBattle() @@ -2000,19 +2490,93 @@ local function walkOntoWarp(wx, wy) stuckOnWarp = 0 end else - local key = bfsNextKey(wx, wy) + local m = ow().map + local cell = p.cellY * m.widthCells + p.cellX + if lastTarget and cell == lastFrom then + reverts[lastTarget] = (reverts[lastTarget] or 0) + 1 + if reverts[lastTarget] >= 2 and not refused[lastTarget] then + say(("walkOntoWarp (%d,%d): the step onto (%d,%d) keeps getting " + .. "shoved back -- planning around it") + :format(wx, wy, lastTarget % m.widthCells, + math.floor(lastTarget / m.widthCells))) + refused[lastTarget] = true + end + end + local key = bfsNextKey(wx, wy, refused) if not key then -- The warp may be walled off by a Cut tree -- the Vermilion Gym -- door at (12,19) sits behind one -- so walkOntoWarp could never -- reach it and travelTo(VERMILION_GYM) spun "did not move" -- forever. Cut a blocking tree and retry; only give up if there -- is none (a genuinely unreachable warp). - if cutToward and cutToward(wx, wy) then - -- way opened; loop re-paths + -- + -- And do NOT give up on the first nil. bfsNextKey walls off + -- every NPC cell -- including the TARGET -- and NPCs wander: a + -- villager standing on (or stepping toward) the door mat makes + -- it "unreachable" for exactly as long as their pause lasts. + -- Giving up instantly is how every Poké Center anchor at + -- Lavender failed ("could not walk onto the door at (3,5)") + -- while the heal point silently stayed a town behind, and tower + -- deaths then replayed the whole Celadon->Lavender walk. Wait + -- them out; only a path that stays missing is real. + if cuts < 3 and cutToward and cutToward(wx, wy) then + cuts = cuts + 1 + bfsNil = 0 -- way opened; loop re-paths + -- ...or by a Silph Co card-key door: the stairs the map planner + -- routes through sit behind locked doors on most Silph floors, + -- so travelTo(SILPH_CO_11F) could never re-climb the building + -- after a death broke the route's own segment sequence. + elseif openDoorToward and openDoorToward(wx, wy) then + bfsNil = 0 + -- ...or by a Pokémon Mansion gate in the wrong switch state + -- (segment 162 stranded the bot at B1F (4,11) exactly here). + elseif not toggledSwitch and toggleMansionSwitch + and toggleMansionSwitch(wx, wy) then + toggledSwitch = true + bfsNil = 0 + -- ...or a Victory Road barrier whose boulder switch is not + -- pressed. travelTo aims at the 1F ladder warp@1,1 straight + -- through the closed barrier block; solve the floor's switches + -- and re-path. Once per call. + elseif not solvedVR and solveVictoryRoadSwitches + and VR_SWITCHES and VR_SWITCHES[ow().map.id] then + solvedVR = true + solveVictoryRoadSwitches() + bfsNil = 0 else - return false + bfsNil = (bfsNil or 0) + 1 + if bfsNil >= 8 then + -- One actionable blocker before giving up: a sleeping + -- SNORLAX with the POKE FLUTE aboard. The route's own + -- flute segment can get skipped in a desync, after which + -- every trip past Route 16's junction dead-ends here + -- ("no path ... SPRITE_SNORLAX@(26,10)") forever. + if wakeBlockingSnorlax and wakeBlockingSnorlax("walkOntoWarp") then + bfsNil = 0 + else + local p2 = ow().player + local who = {} + for _, npc in ipairs(ow().npcs) do + who[#who + 1] = ("%s@(%d,%d)"):format( + tostring(npc.def and (npc.def.id or npc.def.sprite) or "?"), + npc.cellX, npc.cellY) + end + say(("walkOntoWarp (%d,%d) on %s: no path from (%d,%d); npcs: %s") + :format(wx, wy, tostring(ow().map.id), p2.cellX, p2.cellY, + #who > 0 and table.concat(who, " ") or "none")) + return false + end + end + U.wait(20) -- let wanderers step aside / transitions settle end - elseif not walk(key) then + elseif walk(key) then + local dv = DELTA[key] + if dv then + local W = m.widthCells + lastFrom = cell + lastTarget = (math.floor(cell / W) + dv[2]) * W + (cell % W + dv[1]) + end + else U.wait(4) end end @@ -2439,7 +3003,13 @@ local function planMapRoute(from, to, banned) -- a specific warp that just looped us back must not be re-planned, but -- its siblings across the same seam stay available. Across trips the -- memory only makes a seam dearer -- see seamCost. - if not (banned and banned[k]) then + -- + -- The caller stores map-planner bans under "|map|" + -- (namespaced away from the region planner's keys); checking only + -- the bare edgeId here meant NO map-plan ban ever matched, and a + -- warp that could not be reached was re-proposed for the whole + -- 90-hop budget -- the Mansion 1F "warp@4,27 did not move" loop. + if not (banned and (banned[k] or banned[cur .. "|map|" .. k])) then local nd = best + e.cost + seamCost(cur, e.to) if dist[e.to] == nil or nd < dist[e.to] then dist[e.to] = nd @@ -2482,6 +3052,24 @@ local function crossSeam(edge) end end end + -- A seam whose border is ALL SEA (Pallet -> Route 21, the only way to + -- Cinnabar) has no walkable candidates at all. With SURF aboard the + -- water cells are candidates too: ops.goto_'s unreachable ladder mounts + -- at the shore (mountSurfToward) and BFS is water-aware once up. + if #cands == 0 and slotKnowing("SURF") + and (G.save.inventory or {}).SOULBADGE then + if edge.dir == "up" or edge.dir == "down" then + local by = (edge.dir == "up") and 0 or H - 1 + for x = 0, W - 1 do + if m:isWaterCell(x, by) then cands[#cands + 1] = { x, by } end + end + else + local bx = (edge.dir == "left") and 0 or W - 1 + for y = 0, H - 1 do + if m:isWaterCell(bx, y) then cands[#cands + 1] = { bx, y } end + end + end + end table.sort(cands, function(a, b) return math.abs(a[1] - p.cellX) + math.abs(a[2] - p.cellY) < math.abs(b[1] - p.cellX) + math.abs(b[2] - p.cellY) @@ -2529,6 +3117,29 @@ local CONNECTION_CROSS_TRIES = 3 -- Execute one graph hop -- a warp, a connection cross, or a ledge hop -- -- the same way for a region edge or a map edge. +-- Wait until the player is standing on a real, in-bounds cell. A seam +-- crossing has a window where map.id has flipped but the position has not +-- been remapped yet -- travelTo read "SAFFRON_CITY:-1:22" and +-- "ROUTE_7:20:14" there, poisoning its visited/ban sets and leaving BFS +-- planning from an out-of-bounds origin, which is what "cross left did +-- not move x90" after Silph actually was. +local function settleCell() + for _ = 1, 90 do + local o = ow() + local p = o.player + if inBattle() then fightBattle() + elseif busy() then mashUntilIdle() + elseif p.cellX >= 0 and p.cellY >= 0 + and p.cellX < o.map.widthCells and p.cellY < o.map.heightCells + and not p.moving then + return true + else + U.wait(4) + end + end + return false +end + local function doHop(edge) if edge.warp then walkOntoWarp(edge.warp.x, edge.warp.y) @@ -2577,8 +3188,11 @@ local function travelTo(dest, where) -- a row fail to move, drop to the map planner for the rest of the trip: -- region planning where it helps, the proven map planner as the floor, so -- this never leaves the run worse than the map-only baseline. - local REGION_FAIL_LIMIT = 3 + local REGION_FAIL_LIMIT = 6 local regionFails, useMap = 0, false + local secondWind = false + local gateRescued = {} -- edges already given their one gate rescue + local ropedOut = false -- one ESCAPE_ROPE bail per trip (see below) for _ = 1, MAX_TRAVEL_HOPS do local here = ow().map.id if not mapsSeen[here] then @@ -2615,10 +3229,29 @@ local function travelTo(dest, where) end end if not hop then - say(("travelTo %s: no way there from %s"):format(tostring(dest), tostring(here))) - note("travelTo: no route to " .. tostring(dest), where) - return false + -- Dead-ended inside a dungeon with an ESCAPE ROPE aboard: rope to + -- the heal point and re-plan from there, exactly what a lost player + -- does. The Mansion's switch-gate deadlock (1F stairs room vs B1F + -- return path wanting opposite states) is the case; the engine + -- refuses the rope anywhere it is illegal, so this cannot fire in + -- the overworld. + if not ropedOut and (heldCount("ESCAPE_ROPE") or 0) > 0 + and useFieldItem("ESCAPE_ROPE", where) then + ropedOut = true + local landed = ow().map.id + say(("travelTo %s: no way there from %s -- escape-roped to %s") + :format(tostring(dest), tostring(here), tostring(landed))) + if tostring(landed):find("POKECENTER") then leaveByDoor(landed) end + banned = {} + else + say(("travelTo %s: no way there from %s"):format(tostring(dest), tostring(here))) + note("travelTo: no route to " .. tostring(dest), where) + return false + end end + -- roped out above with no hop chosen: re-enter the loop and plan + -- from wherever the rope landed us + if not hop then goto nextTravelHop end local hopDesc = hop.warp and ("warp@" .. hop.warp.x .. "," .. hop.warp.y) or hop.ledge and ("ledge@" .. hop.ledge.x .. "," .. hop.ledge.y) @@ -2650,12 +3283,52 @@ local function travelTo(dest, where) tostring(reachMaps.SAFFRON_CITY ~= nil))) end end + settleCell() local before = posKey() doHop(hop) + settleCell() local pk = posKey() if pk == before then -- did not move at all: this hop is shut from here, ban and re-plan banned[banKey] = true + -- A connection can be unreachable from THIS half of a gate-split + -- map. The map planner sees both halves as one node, so from Route + -- 7's east half it proposed the WEST seam into Celadon ninety times + -- running -- the seam only touches the west half, and the gate in + -- the middle is the only way across. Step through the gate toward + -- a walkable cell on the seam's border, then let the re-plan try + -- again from the far side. + local gx, gy + if hop.cells and hop.cells[1] then + gx, gy = hop.cells[1][1], hop.cells[1][2] + elseif hop.dir then + -- no walkable seam pair recorded: scan this edge for any + -- walkable border cell to aim the gate crossing at + local m2 = ow().map + local W2, H2 = m2.widthCells, m2.heightCells + if hop.dir == "left" or hop.dir == "right" then + local bx = hop.dir == "left" and 0 or W2 - 1 + for y2 = 0, H2 - 1 do + if m2:isWalkableCell(bx, y2) then gx, gy = bx, y2 break end + end + else + local by = hop.dir == "up" and 0 or H2 - 1 + for x2 = 0, W2 - 1 do + if m2:isWalkableCell(x2, by) then gx, gy = x2, by break end + end + end + end + -- ONE gate rescue per edge. reEnterThroughGate can report success + -- for a walk that changed nothing (no gate on the map), and + -- un-banning the edge every time re-proposed the same dead hop + -- forever -- Cinnabar's "cross up" was retried for the full 90-hop + -- budget while the sea sat uncrossed to the west. + if gx and not gateRescued[banKey] and reEnterThroughGate(gx, gy) then + gateRescued[banKey] = true + banned[banKey] = nil + say(("travelTo %s: crossed a gate on %s toward the seam; retrying") + :format(tostring(dest), here)) + end if fromRegion then regionFails = regionFails + 1 if regionFails >= REGION_FAIL_LIMIT and not useMap then @@ -2684,13 +3357,30 @@ local function travelTo(dest, where) end end -- Hard anti-livelock: the same cell revisited far too many times means - -- neither planner is getting anywhere. Give up rather than spin. + -- neither planner is getting anywhere. One SECOND WIND first: the + -- fallback to the map planner is trip-wide, and on a cut-tree-split + -- map (Route 2's Diglett pocket, on the only land route to Pallet) + -- it is blind to the region bridges the region planner knows -- so a + -- few flaky early hops used to condemn the whole trip to a planner + -- that could never finish it. Reset to region planning once, with + -- clean slates, before actually giving up. revisits[pk] = (revisits[pk] or 0) + 1 if revisits[pk] > MAX_TRAVEL_REVISITS then - say(("travelTo %s: stuck revisiting %s -- giving up") - :format(tostring(dest), pk)) - note("travelTo: livelocked reaching " .. tostring(dest), where) - return false + if not secondWind then + secondWind = true + useMap = false + regionFails = 0 + banned = {} + visited = {} + revisits = {} + say(("travelTo %s: second wind -- back to region planning from %s") + :format(tostring(dest), tostring(ow().map.id))) + else + say(("travelTo %s: stuck revisiting %s -- giving up") + :format(tostring(dest), pk)) + note("travelTo: livelocked reaching " .. tostring(dest), where) + return false + end end say(("travelTo %s: looped back to %s; trying another way") :format(tostring(dest), pk)) @@ -2700,6 +3390,7 @@ local function travelTo(dest, where) visited[pk] = true end visited._last = pk + ::nextTravelHop:: end say(("travelTo %s: gave up after %d hops, on %s") :format(tostring(dest), MAX_TRAVEL_HOPS, ow().map.id)) @@ -2723,7 +3414,7 @@ end -- instead of failing to walk to it. -- Cells we can currently walk to, NPCs counted as walls exactly as -- bfsNextKey sees them. Shared by the "walled off" recoveries below. -local function reachableSet() +function reachableSet() local m, p = ow().map, ow().player local W, H = m.widthCells, m.heightCells local blocked = {} @@ -2741,7 +3432,8 @@ local function reachableSet() local nx, ny = c[1] + d[1], c[2] + d[2] local id = ny * W + nx if nx >= 0 and ny >= 0 and nx < W and ny < H and not seen[id] - and not blocked[id] and passableCell(m, nx, ny) then + and not blocked[id] and passableCell(m, nx, ny) + and not pairBlockedStep(m, c[1], c[2], nx, ny, p.surfing) then seen[id] = true q[#q + 1] = { nx, ny } end @@ -2806,7 +3498,11 @@ function reEnterThroughGate(tx, ty) if (back == hereId or back == "LAST_MAP") and not (w2.x == ow().player.cellX and w2.y == ow().player.cellY) then tried = tried + 1 - if tried > 4 then break end + -- 8, not 4: ROUTE_16_GATE_1F has EIGHT LAST_MAP doors back to + -- ROUTE_16 -- four per half, north half listed first -- so a cap + -- of 4 tried only the side we came in from and the crossing + -- always "failed" while the south doors sat untried. + if tried > 8 then break end if walkOntoWarp(w2.x, w2.y) and ow().map.id == hereId then local seen2, W2 = reachableSet() if seen2[ty * W2 + tx] then @@ -3112,7 +3808,7 @@ local function slotForSpecies(prefs) return nil end -local function slotKnowing(moveId) +function slotKnowing(moveId) for i, mon in ipairs(party()) do for _, mv in ipairs(mon.moves or {}) do if tostring(mv.id):upper() == moveId then return i end @@ -3126,6 +3822,14 @@ function ops.teach(s, where) local key = tostring(s.move or ""):lower() local item = TEACH_ITEMS[key] if not item then note("teach:" .. key, where) return true end + -- Idempotent: a blackout replays the segment, and a TM is consumed on + -- use -- so the second pass found "no TM_EARTHQUAKE in the bag" and + -- failed a step whose work was already done (the move was on the lead + -- from the first pass). Somebody already knowing the move IS success. + if slotKnowing(key:upper()) then + say(("teach %s: already known -- skipping"):format(key)) + return true + end if (heldCount(item) or 0) <= 0 then note("teach: no " .. item, where) say(("teach %s: no %s in the bag"):format(key, item)) @@ -3190,8 +3894,23 @@ function ops.teach(s, where) if not menu then break end local t = top() if t == menu then - -- the move list itself: forget slot 1, the starter's weakest filler - if not cursorTo("index", 1) then break end + -- Forget the first move that is NEITHER an HM field move NOR the + -- move being taught. "Slot 1" was blind: after SURF replaced the + -- lead's first move, the next teach (EARTHQUAKE) put the cursor + -- on SURF, the engine refused ("HM moves can't be forgotten") + -- and the menu wedged with the TM half-spent. HM moves are also + -- the run's GEOGRAPHY -- losing SURF strands the Cinnabar leg. + local UNFORGETTABLE = { SURF = true, CUT = true, STRENGTH = true, + FLY = true, FLASH = true, DIG = true } + local row = 1 + for ri, mv in ipairs((menu.mon and menu.mon.moves) or {}) do + local id = tostring(mv.id or mv):upper() + if not UNFORGETTABLE[id] and id ~= key:upper() then + row = ri + break + end + end + if not cursorTo("index", row) then break end press("a") U.wait(10) elseif isChoice() then @@ -3237,6 +3956,23 @@ function ops.fieldMove(s, where) -- won gym behind it. Walking there is slower and always available. if (move == "DIG" or move == "FLY") and nextMapWanted and ow().map.id ~= nextMapWanted then + -- DIG out of a dungeon has an item equivalent we may be carrying: + -- the ESCAPE ROPE warps to the heal point exactly as DIG does. The + -- Mansion is why this matters: segment 162 ends `fieldMove dig` + -- and WALKING out instead runs into the switch-gate deadlock (the + -- 1F stairs room and the B1F return path want opposite states, so + -- the cross-floor statue press ping-pongs forever). The engine + -- refuses the rope on maps where it is illegal, so a failed use + -- falls through to the walk unharmed. + if move == "DIG" and (heldCount("ESCAPE_ROPE") or 0) > 0 then + if useFieldItem("ESCAPE_ROPE", where) then + local landed = ow().map.id + say(("fieldMove DIG: nobody knows it -- escape-roped to %s") + :format(tostring(landed))) + if tostring(landed):find("POKECENTER") then leaveByDoor(landed) end + if ow().map.id == nextMapWanted then return true end + end + end say(("fieldMove %s: nobody knows it -- walking to %s instead") :format(move, tostring(nextMapWanted))) if travelTo(nextMapWanted, where) then return true end @@ -3394,6 +4130,17 @@ end -- cell -- the same test ops.fieldMove uses to find one to face.) local function cuttableCell(m, cx, cy) if not m:inBounds(cx, cy) or m:isWalkableCell(cx, cy) then return false end + -- Mirror the engine's UsedCut gate (OverworldState:tryCut): CUT only + -- works on the OVERWORLD tree tile and the GYM plant tile. Block ids + -- are per-tileset, so matching them alone made every look-alike block + -- on other tilesets read as a tree -- the Saffron/Celadon bush-spam, + -- and a Route 23 (PLATEAU) "tree" whose swap crashed the renderer. + local ts = m.def and m.def.tileset + local tile = m:cellTile(cx, cy) + if not ((ts == "OVERWORLD" and tile == 0x3d) + or (ts == "GYM" and tile == 0x50)) then + return false + end local block = m:blockAt(math.floor(cx / 2), math.floor(cy / 2)) for _, sw in ipairs(G.data.field.cutTreeSwaps or {}) do if sw.before == block then return true end @@ -3462,6 +4209,586 @@ function cutToward(tx, ty) return ok and not cuttableCell(ow().map, best[1], best[2]) end +-- Open a Silph Co card-key door that is walling us off from (tx, ty). +-- +-- The locked-door tile is not walkable and only opens on an A-press while +-- FACING it (OverworldState:tryCardKeyDoor, engine/events/card_key.asm), +-- so to BFS every closed door is a permanent wall. PokeBotBad opens them +-- by bonking A on its way through, so the route has no explicit +-- open-the-door steps -- without this rung, segment 148's walk to +-- Giovanni reported "goto (2,15) unreachable" and the whole Silph ending +-- was silently skipped (which is why the save had CARD_KEY, the beaten +-- 11F trainers, and no EVENT_BEAT_SILPH_CO_GIOVANNI). +-- +-- Same contract as cutToward: one door per call, NPCs are walls, the +-- caller re-plans afterwards. Door positions come from +-- field.cardKeyDoors.doors (data/events/card_key.asm), so this is a +-- lookup, not a tile scan; a door whose tile has already swapped to the +-- open block is skipped. +local openingDoor = false +function openDoorToward(tx, ty) + if openingDoor then return false end + local ck = G.data.field.cardKeyDoors + local m = ow().map + local doors = ck and ck.doors and ck.doors[m.id] + if not doors then return false end + if not G.save.inventory.CARD_KEY then return false end + local W, H = m.widthCells, m.heightCells + local p = ow().player + local blockedBy = {} + for _, npc in ipairs(ow().npcs) do + if npc.cellX >= 0 and npc.cellY >= 0 and npc.cellX < W and npc.cellY < H then + blockedBy[npc.cellY * W + npc.cellX] = true + end + end + local seen = { [p.cellY * W + p.cellX] = true } + local queue, head = { { p.cellX, p.cellY } }, 1 + while head <= #queue do + local c = queue[head]; head = head + 1 + for _, d in ipairs(DIRS) do + local nx, ny = c[1] + d[1], c[2] + d[2] + local id = ny * W + nx + if nx >= 0 and ny >= 0 and nx < W and ny < H and not seen[id] + and not blockedBy[id] and m:isWalkableCell(nx, ny) then + seen[id] = true + queue[#queue + 1] = { nx, ny } + end + end + end + if seen[ty * W + tx] then return false end -- not a door problem + local function closedDoor(dx, dy) + local tile = m:cellTile(dx, dy) + if m.id == "SILPH_CO_11F" then + return tile == ck.silphCo11F.doorTile + end + for _, t in ipairs(ck.doorTiles) do + if tile == t then return true end + end + return false + end + local best, bestD, stand, standFace + for _, d in ipairs(doors) do + if closedDoor(d.x, d.y) then + for _, dv in ipairs(DIRS) do + local ax, ay = d.x + dv[1], d.y + dv[2] + if seen[ay * W + ax] then + local dist = math.abs(d.x - tx) + math.abs(d.y - ty) + if not bestD or dist < bestD then + -- face the OPPOSITE of the offset that took us to the stand + -- cell: standing below the door means facing up + local face = (dv[3] == "up" and "down") or (dv[3] == "down" and "up") + or (dv[3] == "left" and "right") or "left" + best, bestD, stand, standFace = d, dist, { ax, ay }, face + end + end + end + end + end + if not best then return false end + say(("goto (%d,%d) is behind a card-key door; opening the one at " + .. "(%d,%d) from (%d,%d)"):format(tx, ty, best.x, best.y, + stand[1], stand[2])) + openingDoor = true + ops.goto_({ x = stand[1], y = stand[2] }) + local ok = false + local p2 = ow().player + if ow().map.id == m.id and p2.cellX == stand[1] and p2.cellY == stand[2] then + faceDir(standFace) + press("a") + U.wait(10) + mashUntilIdle() -- "Bingo! The CARD KEY opened the door!" + ok = not closedDoor(best.x, best.y) + end + openingDoor = false + if not ok then + say(("card-key door at (%d,%d) on %s did not open") + :format(best.x, best.y, m.id)) + end + return ok +end + +-- Press a Pokémon Mansion statue switch when its walls seal off (tx, ty). +-- +-- One shared toggle, EVENT_MANSION_SWITCH_ON, opens/closes gate blocks on +-- all four floors (data/scripts/story6.lua MANSION_BLOCKS, from pokered's +-- Mansion*CheckReplaceSwitchDoorBlocks). The route flips it via its own +-- talk steps, but a missed talk or a re-planned walk leaves the floor in +-- the wrong state and every plan across a closed gate reads "no path" -- +-- segment 162 sealed the bot at B1F (4,11) with the exit stairs behind a +-- gate, and travelTo burned every rewind against it. +-- +-- Statue cells from story6.lua's mansionFloor registrations; the player +-- stands on the cell BELOW and faces up (all mansion switches face up). +-- The press is TextBox -> ChoiceBox(YES) -> TextBox, which one A plus +-- mashUntilIdle drives. One toggle per call: the caller re-plans, and if +-- the flipped state is still wrong the NEXT call flips it back -- states +-- alternate until the target's side is open. +local MANSION_SWITCHES = { + POKEMON_MANSION_1F = { { 2, 5 } }, + POKEMON_MANSION_2F = { { 2, 11 } }, + POKEMON_MANSION_3F = { { 10, 5 } }, + POKEMON_MANSION_B1F = { { 20, 3 }, { 18, 25 } }, +} +local togglingSwitch = false + +-- Reachable set from where we stand, NPCs as walls (cutToward's fill). +local function mansionReach() + local m = ow().map + local W, H = m.widthCells, m.heightCells + local p = ow().player + local blockedBy = {} + for _, npc in ipairs(ow().npcs) do + if npc.cellX >= 0 and npc.cellY >= 0 and npc.cellX < W and npc.cellY < H then + blockedBy[npc.cellY * W + npc.cellX] = true + end + end + local seen = { [p.cellY * W + p.cellX] = true } + local queue, head = { { p.cellX, p.cellY } }, 1 + while head <= #queue do + local c = queue[head]; head = head + 1 + for _, d in ipairs(DIRS) do + local nx, ny = c[1] + d[1], c[2] + d[2] + local id = ny * W + nx + if nx >= 0 and ny >= 0 and nx < W and ny < H and not seen[id] + and not blockedBy[id] and m:isWalkableCell(nx, ny) then + seen[id] = true + queue[#queue + 1] = { nx, ny } + end + end + end + return seen, W +end + +-- Press any statue on the CURRENT floor that we can reach. All mansion +-- switches are faced upward from the cell below. +local function pressStatueHere() + local m = ow().map + local switches = MANSION_SWITCHES[m.id] + if not switches then return false end + local seen, W = mansionReach() + for _, c in ipairs(switches) do + local sx, sy = c[1], c[2] + 1 + if seen[sy * W + sx] then + ops.goto_({ x = sx, y = sy }) + local p = ow().player + if ow().map.id == m.id and p.cellX == sx and p.cellY == sy then + faceDir("up") + press("a") + U.wait(10) + mashUntilIdle() -- "Press it?" -> YES -> "Who wouldn't?" + return true + end + end + end + return false +end + +-- Push a STRENGTH boulder that blocks the walk to (tx, ty). +-- +-- PokeBotBad moves boulders by WALKING INTO them -- its Victory Road +-- waypoints path straight "through" a boulder, shoving it a cell at a +-- time -- but our BFS treats every NPC as a wall, so the route's pushes +-- never happened: segment 183's boulder sat at (5,15) untouched, the 1F +-- switch was never pressed, and 184-186 skipped against the closed +-- barrier forever. +-- +-- One shove per call, the caller re-plans (the same contract as +-- cutToward). The boulder chosen is the one nearest the straight line to +-- the target; the push direction is the one whose landing cell is free +-- and moves the boulder toward/past the target side, preferring the axis +-- we approach on. STRENGTH is armed on demand (it clears on every map +-- load -- push_boulder.asm). +local pushingBoulder = false +function pushBoulderToward(tx, ty) + if pushingBoulder then return false end + if not slotKnowing("STRENGTH") then return false end + local m = ow().map + local p = ow().player + -- The boulder that matters is the one ON the waypoint (the route walks + -- straight through boulders, so its gotos name the boulder's own cell + -- while shoving it along) or, failing that, the one nearest the + -- TARGET. Nearest-to-player picked Victory Road 1F's far corner + -- boulder and shoved it around while the one the route was actually + -- pushing sat untouched. + local boulder, bestD + for _, npc in ipairs(ow().npcs) do + if tostring(npc.def and npc.def.sprite or ""):find("BOULDER") then + if npc.cellX == tx and npc.cellY == ty then + boulder, bestD = npc, 0 + break + end + local d = math.abs(npc.cellX - tx) + math.abs(npc.cellY - ty) + if not bestD or d < bestD then boulder, bestD = npc, d end + end + end + if not boulder or bestD > 8 then return false end + local bx, by = boulder.cellX, boulder.cellY + pushingBoulder = true + local ok = false + if not ow().strengthActive then + ops.fieldMove({ move = "strength" }, ow().map.id) + end + -- candidate push directions: stand on the opposite side, landing free + local cands = {} + for _, d in ipairs(DIRS) do + local sx, sy = bx - d[1], by - d[2] -- stand cell + local lx, ly = bx + d[1], by + d[2] -- landing cell + if m:inBounds(sx, sy) and m:isWalkableCell(sx, sy) + and not ow():npcAtCell(sx, sy) + and m:inBounds(lx, ly) and m:isWalkableCell(lx, ly) + and not ow():npcAtCell(lx, ly) then + -- score: prefer the direction that carries the boulder toward the + -- target (or onto it), and the stand cell being where we already are + local score = 0 + local dNow = math.abs(bx - tx) + math.abs(by - ty) + local dAfter = math.abs(lx - tx) + math.abs(ly - ty) + if dAfter < dNow then score = score + 2 end + if p.cellX == sx and p.cellY == sy then score = score + 1 end + cands[#cands + 1] = { d = d, sx = sx, sy = sy, score = score } + end + end + table.sort(cands, function(a, b) return a.score > b.score end) + for _, c in ipairs(cands) do + local reached = p.cellX == c.sx and p.cellY == c.sy + if not reached then + ops.goto_({ x = c.sx, y = c.sy }) + local q = ow().player + reached = ow().map.id == m.id and q.cellX == c.sx and q.cellY == c.sy + end + if reached then + say(("pushing the boulder at (%d,%d) %s (toward (%d,%d))") + :format(bx, by, c.d[3], tx, ty)) + for _ = 1, 8 do + faceDir(c.d[3]) + walk(c.d[3], 12) + if not ow():npcAtCell(bx, by) then ok = true break end + U.wait(4) + end + break + end + end + pushingBoulder = false + return ok +end + +-- --------------------------------------------------------------------- +-- Victory Road boulder switches, solved DIRECTLY. +-- +-- The route moves boulders by walking through them (PokeBotBad's +-- collision model lets it), and reproducing that shove-by-shove through +-- the goto ladder proved fragile: one wrong first push and the whole +-- choreography is dead, the switch never presses, and the floor's +-- barrier block stays down while segments 184+ skip against it. +-- +-- These puzzles are static, so plan them outright: a single-boulder +-- sokoban BFS over (boulder cell, player region) that returns the shove +-- list onto the switch cell, executed one stand-face-push at a time. +-- Switch cells and barrier flags from data/scripts/story.lua +-- (scripts/VictoryRoad1F/2F/3F.asm); the 3F hole at (23,15) drops a +-- boulder down beside 2F's second switch, which is where its boulder +-- comes from. +-- --------------------------------------------------------------------- + +VR_SWITCHES = { + VICTORY_ROAD_1F = { + { flag = "EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH", x = 17, y = 13 }, + }, + VICTORY_ROAD_2F = { + { flag = "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1", x = 1, y = 16 }, + { flag = "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2", x = 9, y = 16 }, + }, + VICTORY_ROAD_3F = { + { flag = "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1", x = 3, y = 5 }, + -- the hole: press only while 2F's dropped boulder has not appeared + { hole = true, x = 23, y = 15 }, + }, +} + +-- Shove plan for one boulder onto (tx, ty): BFS over boulder positions, +-- with the player's reachable region (boulder + other NPCs as walls) +-- deciding which sides can be pushed from. Returns { {sx,sy,dir}, ... }. +local function planShoves(m, boulder, tx, ty) + local W, H = m.widthCells, m.heightCells + local walls = {} + for _, npc in ipairs(ow().npcs) do + if npc ~= boulder and npc.cellX >= 0 and npc.cellY >= 0 + and npc.cellX < W and npc.cellY < H then + walls[npc.cellY * W + npc.cellX] = true + end + end + local function open(x, y, bx, by) + if x < 0 or y < 0 or x >= W or y >= H then return false end + if x == bx and y == by then return false end + local id = y * W + x + return not walls[id] and m:isWalkableCell(x, y) + end + -- reachable-region representative for the player with the boulder at + -- (bx,by): the smallest cell id in the flood fill from (px,py) + local function regionRep(px, py, bx, by) + local seen = { [py * W + px] = true } + local q, h, rep = { { px, py } }, 1, py * W + px + while h <= #q do + local c = q[h]; h = h + 1 + for _, d in ipairs(DIRS) do + local nx, ny = c[1] + d[1], c[2] + d[2] + local id = ny * W + nx + -- the player's steps respect tile-pair (elevation) collisions; + -- the BOULDER's landing does not (checkBoulderPush never checks + -- pairs), so only this flood fill filters on them + if not seen[id] and open(nx, ny, bx, by) + and not pairBlockedStep(m, c[1], c[2], nx, ny, false) then + seen[id] = true + if id < rep then rep = id end + q[#q + 1] = { nx, ny } + end + end + end + return rep, seen + end + local p = ow().player + local startRep, startSeen = regionRep(p.cellX, p.cellY, boulder.cellX, boulder.cellY) + local start = { bx = boulder.cellX, by = boulder.cellY, rep = startRep, + seen = startSeen, path = {} } + local visited = { [start.bx .. "," .. start.by .. "," .. start.rep] = true } + local queue, head = { start }, 1 + while head <= #queue do + local n = queue[head]; head = head + 1 + if n.bx == tx and n.by == ty then return n.path end + if #n.path < 60 then + for _, d in ipairs(DIRS) do + local sx, sy = n.bx - d[1], n.by - d[2] + local lx, ly = n.bx + d[1], n.by + d[2] + if open(lx, ly, n.bx, n.by) and n.seen[sy * W + sx] then + local rep2, seen2 = regionRep(n.bx, n.by, lx, ly) + local key = lx .. "," .. ly .. "," .. rep2 + if not visited[key] then + visited[key] = true + local path = {} + for i, s in ipairs(n.path) do path[i] = s end + path[#path + 1] = { sx = sx, sy = sy, dir = d[3] } + queue[#queue + 1] = { bx = lx, by = ly, rep = rep2, + seen = seen2, path = path } + end + end + end + end + end + return nil +end + +-- The solver's stand-cell walks must NEVER leave the floor. ops.goto_'s +-- unreachable ladder (arriveByWarp, reEnterThroughGate, the surf mount) +-- deliberately warps/walks off the map and can report the goto "done" +-- from elsewhere -- which is how three consecutive runs went from +-- "18 shove(s) ... (attempt 1)" to standing on ROUTE_23 with no +-- shove-failure line and no attempt-2 line: a stand cell read +-- unreachable for a moment (the boulder itself, a wanderer, a stale +-- refused cell) and the ladder marched the bot out the entrance. The +-- floor and every path are fully known at plan time, so walk with plain +-- BFS and FAIL FAST; a false return sends the solver back to re-plan +-- from live state, which is cheap. bfsNextKey never paths through a +-- warp cell, so this walker cannot step out of the cave by accident. +local function solverWalkTo(tx, ty) + local m = ow().map + local refused = {} + local stuck = 0 + for _ = 1, 300 do + if ow().map.id ~= m.id then return false end + local p = ow().player + if p.cellX == tx and p.cellY == ty then + if not p.moving then return true end + U.wait(2) + elseif inBattle() then + fightBattle() + elseif busy() then + mashUntilIdle() + else + local key = bfsNextKey(tx, ty, refused) + if not key then + -- a wanderer parked on the path (or the target) may step off; + -- give it a moment, then hand the failure to the solver's retry + stuck = stuck + 1 + if stuck >= 4 then return false end + U.wait(30) + elseif walk(key) then + stuck = 0 + else + -- the engine refused the step: plan around that cell, never + -- escalate + local dv = DELTA[key] + if dv then + refused[(p.cellY + dv[2]) * m.widthCells + (p.cellX + dv[1])] = true + end + stuck = stuck + 1 + if stuck >= 8 then return false end + end + end + end + return false +end + +-- Solve every unpressed switch on the current Victory Road floor. +local solvingBoulders = false +function solveVictoryRoadSwitches() + if solvingBoulders then return end + local m = ow().map + local entries = VR_SWITCHES[m.id] + if not entries then return end + solvingBoulders = true + -- the solver's own gotos must not trigger the ladder's ad-hoc shover + pushingBoulder = true + for _, sw in ipairs(entries) do + local needed + if sw.hole then + local t = (G.save.objectToggles or {}).VICTORY_ROAD_2F + needed = not (t and t.VICTORYROAD2F_BOULDER) + else + needed = not G.save.flags[sw.flag] + end + if needed then + -- A trainer battle or a wanderer can interrupt the execution and + -- leave everything mid-pose (the first live run fought two + -- COOLTRAINERs during shove 1 and drifted out the entrance), so + -- plan-and-execute is retried from LIVE state a few times; the + -- plan is cheap and the switch flag tells us when we are done. + for attempt = 1, 3 do + if ow().map.id ~= m.id then break end + -- try each boulder; the puzzles have exactly one that works + local plan + for _, npc in ipairs(ow().npcs) do + if tostring(npc.def and npc.def.sprite or ""):find("BOULDER") then + plan = planShoves(m, npc, sw.x, sw.y) + if plan then break end + end + end + if not plan then + say(("victory road: no boulder can reach (%d,%d) on %s") + :format(sw.x, sw.y, m.id)) + break + end + say(("victory road: %d shove(s) to put the boulder on (%d,%d)" + .. " (attempt %d)"):format(#plan, sw.x, sw.y, attempt)) + if not ow().strengthActive then + ops.fieldMove({ move = "strength" }, m.id) + end + local clean = true + for i, shove in ipairs(plan) do + local done = false + solverWalkTo(shove.sx, shove.sy) + local q = ow().player + if ow().map.id == m.id and q.cellX == shove.sx and q.cellY == shove.sy then + local dv = DELTA[shove.dir] + local bx, by = shove.sx + dv[1], shove.sy + dv[2] + local lx, ly = bx + dv[1], by + dv[2] + faceDir(shove.dir) + -- Press INTO the boulder one frame at a time and STOP the + -- instant it starts moving. A held key chain-pushes -- the + -- first bump arms (BIT_TRIED_PUSH_BOULDER), the next frame + -- pushes, and if we keep holding, the player follows the + -- boulder in and shoves it again -- which scatters it cells + -- past where the plan wants it. (The arm survives released + -- frames; it only resets when a move attempt finds no + -- boulder in front.) + for _ = 1, 120 do + table.insert(G.input.pressQueue, shove.dir) + G.input.state[shove.dir] = true + coroutine.yield() + G.input.state[shove.dir] = false + if inBattle() then fightBattle() faceDir(shove.dir) end + if not ow():npcAtCell(bx, by) then break end + end + -- let the slide + dust finish, and confirm it landed where + -- the plan expects (not chained past it) + for _ = 1, 90 do + if not busy() and ow():npcAtCell(lx, ly) then break end + U.wait(2) + end + done = ow():npcAtCell(lx, ly) ~= nil + and not ow():npcAtCell(bx, by) + end + if not done then + say(("victory road: shove %d/%d (%s from (%d,%d)) did not move " + .. "the boulder -- re-planning"):format(i, #plan, shove.dir, + shove.sx, shove.sy)) + clean = false + break + end + end + -- drifted out of the cave mid-solve: come back before retrying + if ow().map.id ~= m.id then + local back + for _, w in ipairs(ow().map.def.warps or {}) do + if tostring(w.destMap or "") == m.id then back = w break end + end + if back then walkOntoWarp(back.x, back.y) end + if ow().map.id ~= m.id then break end + end + local pressed + if sw.hole then + local t = (G.save.objectToggles or {}).VICTORY_ROAD_2F + pressed = t and t.VICTORYROAD2F_BOULDER + else + pressed = G.save.flags[sw.flag] + end + if pressed then + say(("victory road: switch at (%d,%d) pressed"):format(sw.x, sw.y)) + break + end + if clean then + -- executed fully yet no flag: geometry bug, stop -- and say so, + -- because a silent return here reads as "the solver never came + -- back" (three runs were mis-diagnosed exactly that way) + say(("victory road: all shoves landed yet (%d,%d) never set its " + .. "flag -- geometry bug, giving up"):format(sw.x, sw.y)) + break + end + end + end + end + pushingBoulder = false + solvingBoulders = false +end + +function toggleMansionSwitch(tx, ty) + if togglingSwitch then return false end + local m = ow().map + if not MANSION_SWITCHES[m.id] then return false end + local seen, W = mansionReach() + if seen[ty * W + tx] then return false end -- not a switch problem + togglingSwitch = true + say(("goto (%d,%d) is sealed by the mansion gates; looking for a switch") + :format(tx, ty)) + local ok = pressStatueHere() + if not ok then + -- No statue on this side of the gates. The 1F basement-stairs room is + -- the case: its only statue sits OUTSIDE the room, but the B1F stairs + -- are inside it -- go down, press one of B1F's two statues, and come + -- back up. (A vanilla player stuck there does exactly this.) + for _, w in ipairs(m.def.warps or {}) do + local dm = tostring(w.destMap or "") + if MANSION_SWITCHES[dm] and seen[w.y * W + w.x] then + local fromMap = m.id + say(("no reachable switch on %s; pressing one on %s instead") + :format(fromMap, dm)) + if walkOntoWarp(w.x, w.y) and MANSION_SWITCHES[ow().map.id] + and ow().map.id ~= fromMap then + ok = pressStatueHere() + -- back through the stairs we came down + local back + for _, w2 in ipairs(ow().map.def.warps or {}) do + if tostring(w2.destMap or "") == fromMap then back = w2 break end + end + if back then walkOntoWarp(back.x, back.y) end + ok = ok and ow().map.id == fromMap + end + break + end + end + end + togglingSwitch = false + return ok +end + -- Push a Strength boulder (Victory Road's four, segments 183-186). -- -- Two things here are easy to get wrong, both from push_boulder.asm @@ -3511,6 +4838,56 @@ function ops.push(s, where) return false end +-- Mount SURF at the nearest shore cell, so an over-water target becomes +-- plannable. Only fires when the party can actually surf (SURF known -- +-- taught at Silph 10F -- and SOULBADGE held); returns true once +-- player.surfing is up, after which bfsNextKey's passableCell opens the +-- water and the caller's re-plan does the rest. The Pallet -> Route 21 -> +-- Cinnabar corridor is why this exists: travelTo could plan the seams but +-- nothing ever stepped OFF the beach. +local mountingSurf = false +function mountSurfToward(tx, ty) + if mountingSurf then return false end + local p = ow().player + if p.surfing then return false end -- already up; not the problem + if not slotKnowing("SURF") then + say("mountSurf: nobody knows SURF") + return false + end + if not (G.save.inventory or {}).SOULBADGE then + say("mountSurf: no SOULBADGE") + return false + end + mountingSurf = true + local m = ow().map + -- water tilesets only (water_tilesets.asm); a cave with no water would + -- scan every cell for nothing + local seen, W = reachableSet() + -- nearest reachable land cell with water beside it + local best, bestD + for cellId in pairs(seen) do + local cx, cy = cellId % W, math.floor(cellId / W) + for _, d in ipairs(DIRS) do + local wx, wy = cx + d[1], cy + d[2] + if m:inBounds(wx, wy) and m:isWaterCell(wx, wy) then + local dist = math.abs(cx - p.cellX) + math.abs(cy - p.cellY) + if not bestD or dist < bestD then best, bestD = { cx, cy }, dist end + break + end + end + end + if not best then mountingSurf = false return false end + if not ops.goto_({ x = best[1], y = best[2] }) then + mountingSurf = false + return false + end + say(("land ran out on the way to (%d,%d) -- surfing from (%d,%d)") + :format(tx, ty, best[1], best[2])) + ops.fieldMove({ move = "surf" }, ow().map.id) + mountingSurf = false + return ow().player.surfing == true +end + -- FLY between towns -- by walking, because we can never actually Fly. -- -- HM02 is picked up at segment 104 (ROUTE_16_FLY_HOUSE) and taught at 105, @@ -3544,6 +4921,18 @@ function ops.fly(s, where) end if ow().map.id == dest then return true end say(("fly to %s: walking there instead"):format(dest)) + -- CINNABAR is an ISLAND: every approach is by sea. Left to itself the + -- planner hunts a land bridge across the northern half of the map and + -- micro-loops in Saffron's houses. Stage through FUCHSIA -- a proven + -- land trip -- and from there the shortest plan is the Route 19/20 + -- water corridor, which crossSeam can now surf. + if dest == "CINNABAR_ISLAND" + and ow().map.id ~= "FUCHSIA_CITY" + and not tostring(ow().map.id):find("ROUTE_19") + and not tostring(ow().map.id):find("ROUTE_20") then + say("staging through FUCHSIA_CITY for the sea route") + travelTo("FUCHSIA_CITY", where) + end if travelTo(dest, where) then return true end note("fly: could not reach " .. dest, where) return false @@ -4166,7 +5555,7 @@ function MANUAL.catchOddish(where) return catchWild(CATCH_SPECIES.oddish, where) -- -- Same bag walk as useItemOn, but there is no party menu at the end: the -- item just fires and warps us to the last heal point. -local function useFieldItem(itemId, where) +function useFieldItem(itemId, where) press("start") U.wait(8) local menu = top() @@ -4226,7 +5615,7 @@ end -- ESCAPE ROPE: it does not land us where the route expects, but standing -- still inside a house guarantees the attempt dies, and walking out at -- least gives the segment loop a chance to re-sync. -local function leaveByDoor(where) +function leaveByDoor(where) local out = findWarpTo("LAST_MAP") or findWarpTo("") if out and walkOntoWarp(out.x, out.y) then say(("left %s by the door (no ESCAPE ROPE)"):format(tostring(where))) @@ -4247,6 +5636,24 @@ function ops.useItem(s, where) if slotKnowing(key) then return true end return ops.teach({ move = s.move, mon = s.mon }, where) end + if item == "REPEL" or item == "SUPER_REPEL" or item == "MAX_REPEL" then + -- The route repels through every dungeon it crosses, and the engine + -- honours it (save.repelSteps). We fight most encounters anyway, but + -- inside the Mansion / Victory Road the attrition is what kills runs: + -- B1F drained BLASTOISE to 2/182 across chained wild fights with an + -- empty bag and the blackout threw the whole arc away. Use whichever + -- repel is actually aboard, strongest first. + for _, id in ipairs({ "MAX_REPEL", "SUPER_REPEL", "REPEL" }) do + if (heldCount(id) or 0) > 0 then + if useFieldItem(id, where) then + say(("used %s on %s"):format(id, ow().map.id)) + return true + end + end + end + note("useItem: no repel aboard", where) + return true -- never blocks the route + end if item ~= "ESCAPE_ROPE" then -- the other useItem steps are speedrun stat items and X ATTACKs note("useItem", where) @@ -4318,6 +5725,24 @@ function MANUAL.playPokeFlute(where) return true end +-- The movement layer's answer to a SNORLAX in the way (walkOntoWarp and +-- ops.goto_ both call this before giving up): with the POKE FLUTE aboard +-- the blocker is an errand, not a wall. The route's own `playPokeFlute` +-- segment is easy to lose to a desync, and without this every later trip +-- past Route 16's junction dead-ends on "SPRITE_SNORLAX@(26,10)" forever. +function wakeBlockingSnorlax(whereFor) + if (heldCount("POKE_FLUTE") or 0) <= 0 then return false end + local found = false + for _, npc in ipairs(ow().npcs) do + local id = tostring((npc.def and (npc.def.id or npc.def.sprite)) or "") + if id:find("SNORLAX") then found = true break end + end + if not found then return false end + say(("a sleeping SNORLAX blocks %s and the POKE FLUTE is aboard -- waking it") + :format(tostring(whereFor))) + return MANUAL.playPokeFlute(whereFor) +end + function ops.manual(s, where) local fn = MANUAL[s.name] if not fn then @@ -4593,6 +6018,8 @@ end local function runRoute(startIndex) local failures, skips = 0, 0 + -- one walk-back attempt per segment index before a skip is allowed + local travelSkipTriedFor = nil -- the last segment we actually ran: a blackout only shows up once we are -- already at the heal point, so this is what the death gets attributed to local lastRanMap @@ -4635,6 +6062,27 @@ local function runRoute(startIndex) blackedOut = false diedOn[i] = (diedOn[i] or 0) + 1 + -- Wait out the respawn BEFORE deciding anything from the map. + -- + -- world.blacked_out fires when the battle decides, not when the warp + -- to the heal point lands, so a caller that reads ow().map.id right + -- away hands us the DEATH map as the wake map. Seen live: "wiped on + -- CELADON_GYM -- woke on CELADON_GYM, resuming from segment 164", + -- after which the gym segment's waypoints ran on whatever map the + -- respawn then delivered and the bot spent minutes cutting trees in + -- circles around Celadon. The respawn always ends inside a Poké + -- Center (or the Pallet bedroom on a fresh save), so wait for the + -- map to actually change / a centre to appear, clearing the blackout + -- text as it comes. + for _ = 1, 200 do + local m = tostring(ow().map.id) + if m:find("POKECENTER") or (m ~= tostring(diedMap) and idle()) then + break + end + if busy() then mashUntilIdle() else U.wait(10) end + end + wokeOn = ow().map.id + -- Losing the lab rival costs nothing in the original: the battle counts -- either way (oaks_lab.lua sets EVENT_BATTLED_RIVAL_IN_OAKS_LAB on a -- loss too), we keep the starter, and the story carries on. So the lab @@ -4685,7 +6133,14 @@ local function runRoute(startIndex) -- beat us again in the same way, and levels are the only variable we -- control. Brock took seven deaths in a row at ten-per-grind, each one -- a free walk back to the same loss. - local gymHere = tostring(ROUTE[i] and ROUTE[i].map):find("GYM") ~= nil + -- Silph Co. counts as a gym for grind cadence: the 7F rival is a + -- fixed roster fought at full heal, exactly the "wall, not variance" + -- shape -- and the wake town (Saffron) has no wild mons, so the + -- ten-death route cadence meant SEVEN potion-stocked wipes before + -- the first level was bought. + local segMapName = tostring(ROUTE[i] and ROUTE[i].map) + local gymHere = segMapName:find("GYM") ~= nil + or segMapName:find("SILPH") ~= nil local everyN = gymHere and DEATHS_PER_GRIND_GYM or DEATHS_PER_GRIND if diedOn[i] % everyN == 0 then say(("segment %d/%d has killed us %dx this attempt -- training before " @@ -4822,6 +6277,20 @@ local function runRoute(startIndex) -- wants us; set it up here so both can see it from the loop's top. nextMapWanted = ROUTE[i + 1] and ROUTE[i + 1].map or nil + -- On-demand checkpoint: `touch /tmp/pokeport_route_savenow` and the + -- run banks its progress (game save + segment) at the next segment + -- boundary. Exists because a driver fix mid-run used to mean killing + -- the process -- and with it every level the Silph attrition war had + -- bought since the last stuck-checkpoint. + do + local marker = io.open("/tmp/pokeport_route_savenow", "r") + if marker then + marker:close() + os.remove("/tmp/pokeport_route_savenow") + saveCheckpoint(i, "requested via savenow marker") + end + end + -- Standing in an elevator? Ride out to the floor we need, before the -- map-wait can react to a bounce. -- @@ -4949,6 +6418,27 @@ local function runRoute(startIndex) end end end + -- WALK to the segment before skipping it -- once per segment index. + -- + -- Skipping exists for segments a cutscene already carried us past. + -- But the route revisits maps, so a run of skips re-enters at the + -- first segment matching wherever we STAND -- which can be a LATER + -- twin of the same map, silently dropping everything between. + -- ROUTE_16_GATE_1F appears at both ~102 and 120: standing there + -- after a stall skipped 112-119 in one burst, and with them the + -- POKE_DOLL, the MAROWAK, and Mr. Fuji's POKE_FLUTE -- after which + -- the SNORLAX is unpassable and the run is dead anyway. A travel + -- that works costs a walk; a skip that is wrong costs the game. + if travelSkipTriedFor ~= i then + travelSkipTriedFor = i + say(("segment %d/%d expects %s -- travelling there before skipping") + :format(i, #ROUTE, seg.map)) + if travelTo(seg.map, here) and ow().map.id == seg.map then + skips = 0 + goto runSegment + end + here = ow().map.id -- a failed trip still moves us + end say(("skipping segment %d/%d: expects %s, on %s") :format(i, #ROUTE, seg.map, here)) failures = failures + 1 @@ -5117,6 +6607,33 @@ local function runRoute(startIndex) end ops.shop({ list = "indigo" }, seg.map) end + -- Saffron is the Silph staging town, and a checkpoint-resumed bag + -- arrives EMPTY -- the shop segments live behind the checkpoint, so + -- the 7F rival was fought potion-less five wipes in a row. When the + -- bag has no HP item, buy some next door before climbing the tower + -- of trainers. + -- Victory Road floors: put the boulders on their switches FIRST, + -- before the segment's own steps walk into (or shove apart) the + -- puzzle. Idempotent -- each switch is guarded by its event flag. + if VR_SWITCHES[seg.map] and ow().map.id == seg.map then + solveVictoryRoadSwitches() + end + local RESTOCK_TOWNS = { SAFFRON_CITY = "saffronRestock", + CINNABAR_ISLAND = "cinnabarRestock" } + local restockList = RESTOCK_TOWNS[seg.map] + if restockList and ow().map.id == seg.map then + local hpItems = 0 + for _, id in ipairs(HP_ITEMS) do hpItems = hpItems + (heldCount(id) or 0) end + if hpItems < 2 then + local mart = findWarpTo("MART") + if mart and walkOntoWarp(mart.x, mart.y) then + say(("bag is out of HP items -- restocking (%s)"):format(restockList)) + ops.shop({ list = restockList }, seg.map) + local out = findWarpTo("LAST_MAP") or findWarpTo("") + if out then walkOntoWarp(out.x, out.y) end + end + end + end if centerCooldown > 0 then centerCooldown = centerCooldown - 1 end -- the one place a nurse detour is safe: between segments, where the -- next segment re-paths from wherever the warp left us diff --git a/tests/parity_I_M.lua b/tests/parity_I_M.lua index 762db222..82306315 100644 --- a/tests/parity_I_M.lua +++ b/tests/parity_I_M.lua @@ -192,21 +192,34 @@ check(actsOn.cut and actsOn.surf and actsOn.strength, "CUT/SURF/STRENGTH submenu entries appear once the badges are held") -- =========================================================================== --- I: CUT from the party menu (CERULEAN_CITY cut tree @18,28, stand @17,28) +-- I: CUT from the party menu. The Cerulean tree BLOCK (50, at block 9,14) +-- spans two unwalkable cells: the fence half at (18,28) (tile $50) and the +-- tree itself at (19,28) (tile $3d). UsedCut (engine/overworld/cut.asm) +-- checks the facing TILE, so only the tree cell cuts -- stand above it at +-- (19,27) facing down, the way a vanilla player does. -- =========================================================================== Game.save.party = { mkMon("BULBASAUR", "CUT") } Game.save.inventory = { CASCADEBADGE = true } -ow = pushOW("CERULEAN_CITY", 17, 28, "right") +ow = pushOW("CERULEAN_CITY", 19, 27, "down") check(ow.map:blockAt(9, 14) == 50, "cut tree block (50) present before CUT") eq(ow:useCutFieldMove(), "ok", "useCutFieldMove ok when facing a cut tree") ow.player.facing = "up" eq(ow:useCutFieldMove(), "nothing", "useCutFieldMove nothing when not facing a tree") -ow.player.facing = "right" +ow.player.facing = "down" Game.save.inventory.CASCADEBADGE = nil eq(ow:useCutFieldMove(), "no_badge", "useCutFieldMove no_badge without CASCADEBADGE") Game.save.inventory.CASCADEBADGE = true +-- the fence half of the same tree block is NOT cuttable (tile $50 on +-- OVERWORLD, not the $3d tree tile) +popToOW() +ow = pushOW("CERULEAN_CITY", 17, 28, "right") +eq(ow:useCutFieldMove(), "nothing", + "useCutFieldMove nothing when facing the tree block's fence cell") +popToOW() +ow = pushOW("CERULEAN_CITY", 19, 27, "down") + -- success path: facing the tree -> _UsedCutText, menu closes, tree replaced clearCaptured() local pmCut = PartyMenu.new(Game) diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 0736fb0e..247cee26 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -731,6 +731,9 @@ do { map = "UNDERGROUND_PATH_ROUTE_6", route = "ROUTE_6", x = 17, y = 13 }, { map = "UNDERGROUND_PATH_ROUTE_7", route = "ROUTE_7", x = 5, y = 13 }, { map = "UNDERGROUND_PATH_ROUTE_8", route = "ROUTE_8", x = 13, y = 3 }, + -- DiglettsCaveRoute{2,11}_Script: same pattern for the cave's two ends + { map = "DIGLETTS_CAVE_ROUTE_2", route = "ROUTE_2", x = 12, y = 9 }, + { map = "DIGLETTS_CAVE_ROUTE_11", route = "ROUTE_11", x = 4, y = 5 }, } for _, c in ipairs(cases) do local rewrite = rewrites[c.map] From 60b3cebdcef755628900f4059bc744a50bff0499 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 21 Jul 2026 13:19:53 -0400 Subject: [PATCH 2/5] ok the auto driver works but needs work --- src/core/FixedStep.lua | 9 +- src/core/Game.lua | 6 +- src/core/GameSpeed.lua | 2 +- src/world/Map.lua | 40 ++++ src/world/OverworldController.lua | 15 +- tests/drivers/route.lua | 383 +++++++++++++++++++++++++++--- 6 files changed, 419 insertions(+), 36 deletions(-) diff --git a/src/core/FixedStep.lua b/src/core/FixedStep.lua index e31ea8f6..398270b3 100644 --- a/src/core/FixedStep.lua +++ b/src/core/FixedStep.lua @@ -12,8 +12,15 @@ function FixedStep:init(callback) self.callback = callback end +-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15 +-- steps), which silently throttled the high fast-forward levels: at 100X +-- a 60fps frame wants ~100 steps. Game:update raises this to fit the +-- current speed target; a stall still cannot snowball past one frame's +-- intended budget. +FixedStep.maxAccum = MAX_ACCUM + function FixedStep:update(dt) - self.accum = math.min(self.accum + dt, MAX_ACCUM) + self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM) while self.accum >= self.STEP do self.accum = self.accum - self.STEP self.callback(self.STEP) diff --git a/src/core/Game.lua b/src/core/Game.lua index 09ef6905..e4b3e022 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -170,7 +170,11 @@ function Game:update(dt) -- deferred A and edge pulses land in Input's press queue for this step. TouchInput:update(dt) -- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua). - FixedStep:update(dt * self:logicSpeed()) + -- Give the accumulator room for one full frame at the current speed, + -- or the anti-spiral clamp quietly caps every level above ~15X. + local speed = self:logicSpeed() + FixedStep.maxAccum = math.max(0.25, speed * FixedStep.STEP * 1.5) + FixedStep:update(dt * speed) -- 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. diff --git a/src/core/GameSpeed.lua b/src/core/GameSpeed.lua index bfdf2136..a21309d0 100644 --- a/src/core/GameSpeed.lua +++ b/src/core/GameSpeed.lua @@ -18,7 +18,7 @@ local GameSpeed = {} -- 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.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75, 100,500 } GameSpeed.DEFAULT = 1 function GameSpeed.levelLabel(v) diff --git a/src/world/Map.lua b/src/world/Map.lua index d13910ee..f4061a08 100644 --- a/src/world/Map.lua +++ b/src/world/Map.lua @@ -29,6 +29,46 @@ local function hashSet(list, into) return into end +-- Passability of a cell of an UNLOADED map def -- the connected neighbor +-- during an edge crossing. pokered's collision check reads the neighbor +-- strip's tile bytes, so a step off the map edge onto a solid tile of +-- the connected map bumps exactly like an in-map wall; the port needs +-- the same read without building the whole Map. Same math as cellTile +-- on the raw blocks, honoring the surf rule (water/shore passable only +-- while surfing, same fallbacks as Map.new). +function Map.defPassable(def, tilesetDef, cx, cy, surfing) + if not (def and tilesetDef and tilesetDef.blocks and tilesetDef.walkable) then + return true -- no data to judge with: keep the old permissive behavior + end + local tx, ty = cx * 2, cy * 2 + 1 + local bx, by = math.floor(tx / 4), math.floor(ty / 4) + local id + if bx < 0 or by < 0 or bx >= def.width or by >= def.height then + id = def.borderBlock + else + id = def.blocks[by * def.width + bx + 1] + end + local block = tilesetDef.blocks[(id or 0) + 1] + if not block then return false end + local tile = block[(ty % 4) * 4 + (tx % 4) + 1] + for _, t in ipairs(tilesetDef.walkable) do + if t == tile then return true end + end + if surfing then + for _, t in ipairs(tilesetDef.waterTiles or WATER_TILES) do + if t == tile then return true end + end + local shore = tilesetDef.shoreTiles + if shore == nil and not NO_SHORE_TILESETS[def.tileset] then + shore = SHORE_TILES + end + for _, t in ipairs(shore or {}) do + if t == tile then return true end + end + end + return false +end + function Map.new(def, tilesetDef) local self = setmetatable({}, Map) self.def = def diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index d872ae90..6e840b25 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -883,8 +883,7 @@ function OverworldState:checkEdgeExit(dir) local conn = self.map:connection(COMPASS[dir]) if conn then - self:crossConnection(dir, conn) - return true + return self:crossConnection(dir, conn) end return false end @@ -898,7 +897,7 @@ function OverworldState:crossConnection(dir, conn) local dest = Game.data.maps[conn.map] if not dest then Logger.warn("connection to unknown map %s", tostring(conn.map)) - return + return false end local p = self.player local x, y = p.cellX, p.cellY @@ -914,6 +913,15 @@ function OverworldState:crossConnection(dir, conn) end x = math.max(0, math.min(destW - 1, x)) y = math.max(0, math.min(destH - 1, y)) + -- pokered's collision check reads the NEIGHBOR strip's tile bytes, so + -- stepping off the edge onto a solid tile of the connected map bumps + -- exactly like an in-map wall. Without this read, Pallet's south + -- shore (land at x2-3) walked straight onto ROUTE_21 (3,0) -- a + -- collision tile -- stranding the player on a cell no walk can leave. + if not Map.defPassable(dest, Game.data.tilesets[dest.tileset], x, y, + p.surfing) then + return false + end self:setMap(conn.map, x, y, p.facing, { seamless = true }) -- place the player one cell before the seam (their old world spot, -- which the neighbor strip renders identically) and start the step @@ -931,6 +939,7 @@ function OverworldState:crossConnection(dir, conn) p.stepFramesCur = Game.save.onBike and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8) or (FieldDefaults.world(Game.data, "stepFrames") or 16) + return true end -- ------------------------------------------------------------------------- diff --git a/tests/drivers/route.lua b/tests/drivers/route.lua index 776da3b0..c8bc31de 100644 --- a/tests/drivers/route.lua +++ b/tests/drivers/route.lua @@ -1104,6 +1104,9 @@ local mountSurfToward -- travelTo); declared here for the same reason as cutToward. local arriveByWarp +-- pickupBlockingBall (defined below, next to reachableSet) is a GLOBAL, +-- like cutToward: the main chunk is at LuaJIT's 200-local ceiling. + -- Cross to another region of the same map through a gate. Assigned below -- (needs walkOntoWarp); declared here because ops.goto_ calls it. local reEnterThroughGate @@ -1141,10 +1144,14 @@ end function ops.goto_(s, _where, isLast) local o = ow() local map = o.map - -- one surf mount per goto: walking ashore dismounts, so an unreachable - -- target beyond a pond can otherwise mount -> cross -> dismount -> fail - -- -> mount again forever (Celadon's pond, target (39,8)) - local surfMounts = 0 + -- surf mounts per goto, BUDGETED: walking ashore dismounts, so an + -- unreachable target beyond a pond can mount -> cross -> dismount -> + -- fail -> mount forever (Celadon's pond, target (39,8)) -- but a hard + -- cap of ONE stranded the bot on Route 20's mid-island strip, where a + -- legitimate crossing is surf -> beach -> surf again. Three mounts + -- covers every real island-hop; a still-unreachable target then fails + -- the goto as before. + local surfMounts, SURF_BUDGET = 0, 3 -- ...and a small cut budget per goto: a pocket behind two trees opens -- over two passes, but a target that stays unreachable must not -- chain-cut every ornamental bush on the map (Saffron/Celadon cut-spam) @@ -1171,6 +1178,8 @@ function ops.goto_(s, _where, isLast) local tx = math.max(0, math.min(s.x, map.widthCells - 1)) local ty = math.max(0, math.min(s.y, map.heightCells - 1)) local refusedEdge = knownWalls(map.id) or {} + -- one border-cell retarget per goto (see the fenced-lane rung below) + local edgeRetargeted = false for edgeTry = 1, 2 do for _ = 1, 300 do if ow().map.id ~= startMap then return true end @@ -1206,11 +1215,43 @@ function ops.goto_(s, _where, isLast) end -- ...or the border cell is on WATER (Pallet's south edge): -- mount SURF at the shore and re-plan over the sea. - if surfMounts == 0 and mountSurfToward + if surfMounts < SURF_BUDGET and mountSurfToward and mountSurfToward(tx, ty) then surfMounts = surfMounts + 1 goto edgeStep end + -- ...or THIS border column is fenced off while another of + -- the same edge is open. Viridian's south border is three + -- fenced lanes and only x20-21 connects to the city; the + -- planner's seam cell (15,35) is statically walkable-paired + -- but dynamically sealed, so the exit spun cutting + -- ornamental bushes and surfing the pond. Retarget ONCE to + -- the nearest border cell that is BOTH reachable right now + -- and has a walkable far side. + if not edgeRetargeted then + local compass = (dir == "up" and "north") + or (dir == "down" and "south") + or (dir == "left" and "west") or "east" + local conn = map.def.connections and map.def.connections[compass] + if conn and seamCells and reachableSet then + local seen, WW = reachableSet() + local bestAlt, bestD + for _, c in ipairs(seamCells(map.def, compass, conn)) do + if seen[c[2] * WW + c[1]] and (c[1] ~= tx or c[2] ~= ty) then + local d2 = math.abs(c[1] - tx) + math.abs(c[2] - ty) + if not bestD or d2 < bestD then bestAlt, bestD = c, d2 end + end + end + if bestAlt then + say(("edge exit %s on %s: border cell (%d,%d) is walled " + .. "off; crossing at (%d,%d) instead"):format(dir, + tostring(map.id), tx, ty, bestAlt[1], bestAlt[2])) + tx, ty = bestAlt[1], bestAlt[2] + edgeRetargeted = true + goto edgeStep + end + end + end end break -- already there, or genuinely no path end @@ -1250,7 +1291,7 @@ function ops.goto_(s, _where, isLast) -- The push stalled. Once per goto: if this edge has a WATER crossing -- and the party can surf, mount and retry the whole exit over the -- sea column instead of bumping the shore (Cinnabar -> Route 21). - if edgeTry == 1 and surfMounts == 0 and not ow().player.surfing + if edgeTry == 1 and surfMounts < SURF_BUDGET and not ow().player.surfing and slotKnowing("SURF") then local wc = borderWaterCell(map, dir, tx, ty) if wc and mountSurfToward and mountSurfToward(wc[1], wc[2]) then @@ -1353,12 +1394,21 @@ function ops.goto_(s, _where, isLast) for _ = 1, 8 do if ow().map.id ~= from then return true end local m = ow().map - -- only a warp ON the map border needs the held direction; an - -- interior door fires on its own and has no "outward" to push - local outward = (s.x >= m.widthCells - 1 and "right") - or (s.x <= 0 and "left") - or (s.y >= m.heightCells - 1 and "down") - or (s.y <= 0 and "up") + -- A warp needing a held press: mirror the engine's + -- Warp.extraCheck -- facing the map edge, OR a warp-carpet + -- door tile in front (Route 16's gate mats: plain path + -- tiles whose warp fires only pressing INTO the building). + -- A true interior door fires on its own; no direction + -- passes extraCheck there and we stop pushing. + local WarpMod = require("src.world.Warp") + local carpets = G.data.field and G.data.field.warpCarpets + local outward + for _, dd in ipairs(DIRS) do + if WarpMod.extraCheck(m, carpets, s.x, s.y, dd[3]) then + outward = dd[3] + break + end + end if not outward then break end pushed = true faceDir(outward) @@ -1464,6 +1514,15 @@ function ops.goto_(s, _where, isLast) blocked = 0 goto keepGoing end + -- ...or an item ball is the wall (Pokémon Tower 6F's + -- RARE_CANDY sits in the floor's only crossing corridor). + -- Tried BEFORE arriveByWarp: taking the ball keeps the walk on + -- this floor, while arriveByWarp warps clear off it hunting + -- the far end. + if pickupBlockingBall and pickupBlockingBall(s.x, s.y) then + blocked = 0 + goto keepGoing + end -- ...or the target is in a part of this map no walk connects. -- -- Cave floors are not one connected room. MT_MOON_B1F is four @@ -1533,7 +1592,7 @@ function ops.goto_(s, _where, isLast) -- an NPC-blocked target in a pond town does not trigger a -- pointless swim, and ONCE per goto -- coming ashore dismounts, -- so a still-unreachable target re-mounts forever otherwise. - if surfMounts == 0 and mountSurfToward + if surfMounts < SURF_BUDGET and mountSurfToward and mountSurfToward(s.x, s.y) then surfMounts = surfMounts + 1 blocked = 0 @@ -2439,6 +2498,8 @@ local function walkOntoWarp(wx, wy) local toggledSwitch = false -- one Victory Road switch solve per call (see solveVictoryRoadSwitches) local solvedVR = false + -- one blocking-item-ball pickup per call (see pickupBlockingBall) + local tookBall = false for _ = 1, 300 do if ow().map.id ~= from then return true end if inBattle() then fightBattle() @@ -2456,11 +2517,28 @@ local function walkOntoWarp(wx, wy) -- "leave by this edge" idiom -- which is Red shoving downward into -- the wall below the door instead of walking out of it. local m = ow().map - local outward = (wy >= m.heightCells - 1 and "down") - or (wy <= 0 and "up") - or (wx <= 0 and "left") - or (wx >= m.widthCells - 1 and "right") - or "down" + -- Which press can actually FIRE this warp? Mirror the engine: + -- Warp.extraCheck says whether pressing `dir` from this cell can + -- warp -- facing the map edge, OR a warp-carpet door tile in + -- front. Route 16's gate mats are the latter: plain path tiles + -- whose warp fires only pressing INTO the building ($4B door to + -- the LEFT), and the old edge-based guess pushed "down" forever + -- ("the step onto (24,10) keeps getting shoved back"). + local WarpMod = require("src.world.Warp") + local carpets = G.data.field and G.data.field.warpCarpets + local outward + for _, d in ipairs(DIRS) do + if WarpMod.extraCheck(m, carpets, wx, wy, d[3]) then + outward = d[3] + break + end + end + outward = outward + or (wy >= m.heightCells - 1 and "down") + or (wy <= 0 and "up") + or (wx <= 0 and "left") + or (wx >= m.widthCells - 1 and "right") + or "down" faceDir(outward) walk(outward) -- If pushing outward does nothing, the warp we are standing on is @@ -2543,6 +2621,12 @@ local function walkOntoWarp(wx, wy) solvedVR = true solveVictoryRoadSwitches() bfsNil = 0 + -- ...or an item ball on the only corridor (Pokémon Tower 6F's + -- RARE_CANDY): take it and re-path. + elseif not tookBall and pickupBlockingBall + and pickupBlockingBall(wx, wy) then + tookBall = true + bfsNil = 0 else bfsNil = (bfsNil or 0) + 1 if bfsNil >= 8 then @@ -2638,7 +2722,10 @@ end -- pairing is exact and a seam with no walkable pair can never be crossed -- however hard we push at it. This is what keeps the search from planning -- Vermilion -> Saffron -> Cerulean through a walled city. -local function seamCells(def, dir, conn) +-- GLOBAL (not local): ops.goto_'s edge-exit retarget below needs it and +-- is defined earlier in the file; the main chunk is also at LuaJIT's +-- 200-local ceiling. +function seamCells(def, dir, conn) local dest = G.data.maps and G.data.maps[conn.map] if not dest then return {} end local aW, aH = def.width * 2, def.height * 2 @@ -2770,14 +2857,16 @@ local function regionGraph() local dw = maps[src].warps and maps[src].warps[w.destWarp] local dr = dw and cellRegionOf(src, dw.x, dw.y) if sr and dr then + local c = (id:find("^SEAFOAM") or src:find("^SEAFOAM")) and 43 or 3 add(regionNode(id, sr), regionNode(src, dr), - { warp = { x = w.x, y = w.y }, cost = 3, dynamic = true }) + { warp = { x = w.x, y = w.y }, cost = c, dynamic = true }) end end elseif type(d) == "string" and maps[d] then local dw = maps[d].warps and maps[d].warps[w.destWarp] local dr = dw and cellRegionOf(d, dw.x, dw.y) - if sr and dr then + if sr and dr and not (d:find("^SEAFOAM") and not id:find("^SEAFOAM")) then + -- entering Seafoam pruned, same reason as the map graph local c = (id:find("ELEVATOR") or d:find("ELEVATOR")) and 50 or 1 add(regionNode(id, sr), regionNode(d, dr), { warp = { x = w.x, y = w.y }, cost = c }) @@ -2802,8 +2891,13 @@ local function regionGraph() local k = tostring(sr) .. ">" .. tostring(dr) if sr and dr and not seen[k] then seen[k] = true - add(regionNode(id, sr), regionNode(conn.map, dr), - { dir = COMPASS_DIR[dir], cells = cells, cost = 1 }) + -- ROUTE_20 <-> CINNABAR: pruned, same reason as the map + -- graph (the east and west seas never connect) + if not ((id == "ROUTE_20" and conn.map == "CINNABAR_ISLAND") + or (id == "CINNABAR_ISLAND" and conn.map == "ROUTE_20")) then + add(regionNode(id, sr), regionNode(conn.map, dr), + { dir = COMPASS_DIR[dir], cells = cells, cost = 1 }) + end end end end @@ -2945,7 +3039,17 @@ local function mapGraph() -- rather than deleted so a floor only the elevator can reach (Silph's -- top) is still on the graph. local elev = (id:find("ELEVATOR") or d:find("ELEVATOR")) and 50 or 1 - g[id][#g[id] + 1] = { to = d, warp = w, cost = elev } + -- ENTERING Seafoam is pruned outright: 1F's two door pockets + -- only connect through the BASEMENT floors, which hop execution + -- cannot thread, so every plan through the islands ping-ponged + -- the Route 20 doors -- pricing alone lost to a wall of banned + -- alternatives after hours of wandering. The route never uses + -- Seafoam (Cinnabar is reached by sea from Pallet). Exits (the + -- LAST_MAP door edges) are kept so a bot stranded inside can + -- always leave. + if not (d:find("^SEAFOAM") and not id:find("^SEAFOAM")) then + g[id][#g[id] + 1] = { to = d, warp = w, cost = elev } + end into[d] = into[d] or {} into[d][id] = true end @@ -2958,8 +3062,23 @@ local function mapGraph() -- gate script can open one later, and a wrongly deleted edge is -- an unreachable map, while a wrongly costly one is a detour. local cells = seamCells(def, dir, c) - g[id][#g[id] + 1] = { to = c.map, dir = COMPASS_DIR[dir], - cells = cells, cost = #cells > 0 and 1 or 8 } + -- ROUTE_20's east and west seas NEVER connect on the map: a + -- walled channel splits them at Seafoam, and the only crossing + -- is through the islands' basement, which hop execution cannot + -- thread. The map graph sees one ROUTE_20 node, so from the + -- east side the CINNABAR seam looked one hop away, and one + -- travelTo(PALLET_TOWN) spent HOURS banning its way across + -- Kanto until this impossible seam was the cheapest edge left, + -- then camped it. PRUNED, not priced -- this is a hard physical + -- fact, not a maybe-a-tree-opens-it seam, and both sides stay + -- reachable without it (CINNABAR via ROUTE_21, ROUTE_20 via + -- ROUTE_19). The executable Cinnabar path is Pallet -> + -- ROUTE_21 south, the HOF run's own. + if not ((id == "ROUTE_20" and c.map == "CINNABAR_ISLAND") + or (id == "CINNABAR_ISLAND" and c.map == "ROUTE_20")) then + g[id][#g[id] + 1] = { to = c.map, dir = COMPASS_DIR[dir], + cells = cells, cost = #cells > 0 and 1 or 8 } + end end end end @@ -2972,7 +3091,8 @@ local function mapGraph() for _, w in ipairs(def.warps or {}) do if w.destMap == "LAST_MAP" then for src in pairs(into[id] or {}) do - g[id][#g[id] + 1] = { to = src, warp = w, dynamic = true, cost = 3 } + local c = (id:find("^SEAFOAM") or src:find("^SEAFOAM")) and 43 or 3 + g[id][#g[id] + 1] = { to = src, warp = w, dynamic = true, cost = c } end end end @@ -3436,12 +3556,74 @@ function reachableSet() and not pairBlockedStep(m, c[1], c[2], nx, ny, p.surfing) then seen[id] = true q[#q + 1] = { nx, ny } + elseif nx >= 0 and ny >= 0 and nx < W and ny < H + and not passableCell(m, nx, ny) then + -- ledge hops, so this set matches what bfsNextKey can walk + -- (see cutToward for the ROUTE_9 case this fixed) + local lx, ly = ledgeLanding(m, c[1], c[2], d[3], d[1], d[2]) + if lx then + local lid = ly * W + lx + if not seen[lid] and not blocked[lid] then + seen[lid] = true + q[#q + 1] = { lx, ly } + end + end end end end return seen, W end +-- Pick up an item ball that is walling off the walk to (tx, ty). +-- +-- An item ball is an NPC and therefore a BFS wall -- and Pokémon Tower +-- 6F's RARE_CANDY at (6,8) stands in the ONLY corridor between the +-- floor's east and west halves. Vanilla players grab it in passing (the +-- ball despawns, opening the way) and the route's own pickup steps do +-- the same -- but a resume desync that skips those steps leaves the +-- floor severed for good: "goto (6,14) unreachable on POKEMON_TOWER_6F" +-- with a fresh save's balls all standing. So: when the target is +-- outside the reachable set and a ball sits on its frontier, stand +-- beside it, take it, and let the caller re-plan. Same one-per-call +-- contract as cutToward. +pickingBall = false -- global: the main chunk is at the 200-local ceiling +function pickupBlockingBall(tx, ty) + if pickingBall then return false end + local m = ow().map + local seen, W = reachableSet() + if seen[ty * W + tx] then return false end -- not a blocking-ball problem + for _, npc in ipairs(ow().npcs) do + if tostring(npc.def and npc.def.sprite or "") == "SPRITE_POKE_BALL" then + local bx, by = npc.cellX, npc.cellY + for _, d in ipairs(DIRS) do + local ax, ay = bx + d[1], by + d[2] + if ax >= 0 and ay >= 0 and ax < W and ay < m.heightCells + and seen[ay * W + ax] then + pickingBall = true + local took = false + ops.goto_({ x = ax, y = ay }) + local p = ow().player + if ow().map.id == m.id and p.cellX == ax and p.cellY == ay then + faceDir((by < ay and "up") or (by > ay and "down") + or (bx < ax and "left") or "right") + press("a") + U.wait(20) + mashUntilIdle() + took = not ow():npcAtCell(bx, by) + end + pickingBall = false + if took then + say(("picked up the item ball at (%d,%d) that was walling off " + .. "(%d,%d)"):format(bx, by, tx, ty)) + return true + end + end + end + end + end + return false +end + -- Reach another region of THIS map by going through a gate and out its far -- door. -- @@ -3945,6 +4127,8 @@ end -- CASCADEBADGE (src/ui/PartyMenu.lua:342). function ops.fieldMove(s, where) local move = tostring(s.move or ""):upper() + -- the tree cell CUT will face, for the post-use verification below + local cutCellX, cutCellY local slot = slotKnowing(move) if not slot then -- DIG and FLY are TRANSPORT, and a transport step we cannot perform is @@ -3981,6 +4165,21 @@ function ops.fieldMove(s, where) say(("fieldMove %s: nobody in the party knows it"):format(move)) return false end + -- The knower must also be CONSCIOUS: the party submenu hides field + -- moves on a fainted mon (vanilla -- partyKnows requires hp > 0), so + -- the menu walk below finds only STATS/SWITCH and fails. Route 23's + -- mandatory surf strip is where this bit: WARTORTLE arrived dead from + -- the Viridian Gym attrition, "no SURF entry" x3, the water was never + -- crossed, and the whole league skip-cascaded with all eight badges + -- in the bag. Heal, fail this attempt loudly, and let the caller's + -- retry run the step again with a live user. + local knower = (G.save.party or {})[slot] + if knower and (knower.hp or 0) <= 0 then + say(("fieldMove %s: its only user has fainted -- visiting a centre " + .. "before retrying"):format(move)) + visitPokeCenter(where) + return false + end -- CUT acts on the tile the player is FACING (OverworldState:tryCut), and -- a `goto` leaves us facing whatever direction we last stepped. Without -- turning to the tree first the move fires into empty ground, the game @@ -3991,6 +4190,19 @@ function ops.fieldMove(s, where) local m = ow().map local function cuttable(cx, cy) if not m:inBounds(cx, cy) or m:isWalkableCell(cx, cy) then return false end + -- The same FACE gate as the engine (tryCut): a tree BLOCK is 2x2 + -- cells and only the tree-tile half is cuttable -- facing the + -- fence/sign half answers "Nothing to CUT". Without this the scan + -- below picked whichever unwalkable half it met first: Route 9's + -- tree at (5,8) has the block's sign half at (4,9), DOWN is + -- scanned before RIGHT, and the run cut "the tree" twenty times + -- from (4,8) without ever opening the way east. + local ts = m.def and m.def.tileset + local tile = m:cellTile(cx, cy) + if not ((ts == "OVERWORLD" and tile == 0x3d) + or (ts == "GYM" and tile == 0x50)) then + return false + end local block = m:blockAt(math.floor(cx / 2), math.floor(cy / 2)) for _, sw in ipairs(G.data.field.cutTreeSwaps or {}) do if sw.before == block then return true end @@ -4001,7 +4213,11 @@ function ops.fieldMove(s, where) local facing for _, d in ipairs({ { 0, 1, "down" }, { 0, -1, "up" }, { -1, 0, "left" }, { 1, 0, "right" } }) do - if cuttable(p.cellX + d[1], p.cellY + d[2]) then facing = d[3] break end + if cuttable(p.cellX + d[1], p.cellY + d[2]) then + facing = d[3] + cutCellX, cutCellY = p.cellX + d[1], p.cellY + d[2] + break + end end if not facing then note("fieldMove: no tree adjacent", where) @@ -4102,6 +4318,19 @@ function ops.fieldMove(s, where) press("a") U.wait(12) mashUntilIdle() + -- CUT is verifiable: the faced tree cell must be walkable afterwards. + -- "used CUT" used to print regardless, so an engine refusal ("Nothing + -- to CUT") looked like success and the caller re-cut the same tree + -- for its whole budget. + if move == "CUT" and cutCellX then + local now = ow().map + if now:inBounds(cutCellX, cutCellY) + and not now:isWalkableCell(cutCellX, cutCellY) then + say(("fieldMove CUT: the tree at (%d,%d) is still standing -- the " + .. "cut did not land"):format(cutCellX, cutCellY)) + return false + end + end say(("used %s on %s"):format(move, tostring(where))) -- DIG and FLY are the route's long-distance travel, and neither lands @@ -4182,6 +4411,22 @@ function cutToward(tx, ty) and not blockedBy[id] and m:isWalkableCell(nx, ny) then seen[id] = true queue[#queue + 1] = { nx, ny } + elseif nx >= 0 and ny >= 0 and nx < W and ny < H + and not m:isWalkableCell(nx, ny) then + -- ledge hops reach cells this flood otherwise misses -- and + -- bfsNextKey WALKS them, so leaving them out makes the two + -- disagree: from ROUTE_9 (46,9) the regrown west tree's face is + -- only ledge-reachable, cutToward found "no tree", and + -- travelTo(CINNABAR_ISLAND) died -- taking Blaine, the 7-badge + -- Viridian Gym and the whole league gate with it. + local lx, ly = ledgeLanding(m, c[1], c[2], d[3], d[1], d[2]) + if lx then + local lid = ly * W + lx + if not seen[lid] and not blockedBy[lid] then + seen[lid] = true + queue[#queue + 1] = { lx, ly } + end + end end end end @@ -4863,14 +5108,21 @@ function mountSurfToward(tx, ty) -- water tilesets only (water_tilesets.asm); a cave with no water would -- scan every cell for nothing local seen, W = reachableSet() - -- nearest reachable land cell with water beside it + -- The shore to mount from is the reachable land-beside-water cell + -- nearest the TARGET, with the player's own distance only breaking + -- ties. Nearest-to-player was a trap on Route 20's mid-island strip: + -- a rock wall splits the strip's shores, and the bot re-mounted on + -- the west shore every time, surfed back over ground it had already + -- crossed, dismounted in the same spot and burned the whole mount + -- budget without ever entering the water that leads east. local best, bestD for cellId in pairs(seen) do local cx, cy = cellId % W, math.floor(cellId / W) for _, d in ipairs(DIRS) do local wx, wy = cx + d[1], cy + d[2] if m:inBounds(wx, wy) and m:isWaterCell(wx, wy) then - local dist = math.abs(cx - p.cellX) + math.abs(cy - p.cellY) + local dist = (math.abs(cx - tx) + math.abs(cy - ty)) * 4 + + math.abs(cx - p.cellX) + math.abs(cy - p.cellY) if not bestD or dist < bestD then best, bestD = { cx, cy }, dist end break end @@ -6024,6 +6276,26 @@ local function runRoute(startIndex) -- already at the heal point, so this is what the death gets attributed to local lastRanMap local diedOn = {} -- segment index -> deaths this attempt + -- Badge go-back (see the league-gate check in the loop): badges whose + -- gym segment this attempt has already rewound to, so a gym that + -- cannot be won does not loop the rewind forever. + local badgeRescued = {} + local LEAGUE_GATE_MAPS = { + ROUTE_22_GATE = true, ROUTE_23 = true, VICTORY_ROAD_1F = true, + VICTORY_ROAD_2F = true, VICTORY_ROAD_3F = true, + INDIGO_PLATEAU = true, INDIGO_PLATEAU_LOBBY = true, + } + -- route order; badge names are inventory keys, gyms are segment maps + local BADGE_GYM_SEGMENTS = { + { "BOULDERBADGE", "PEWTER_GYM" }, + { "CASCADEBADGE", "CERULEAN_GYM" }, + { "THUNDERBADGE", "VERMILION_GYM" }, + { "RAINBOWBADGE", "CELADON_GYM" }, + { "SOULBADGE", "FUCHSIA_GYM" }, + { "MARSHBADGE", "SAFFRON_GYM" }, + { "VOLCANOBADGE", "CINNABAR_GYM" }, + { "EARTHBADGE", "VIRIDIAN_GYM" }, + } -- segment index -> deaths in a row where training gained no levels. -- Reported, not acted on: no death count writes off a segment any more. -- A long barren streak means the grind cannot reach a training ground @@ -6291,6 +6563,54 @@ local function runRoute(startIndex) end end + -- Rolling checkpoint every 10 segments, no external toucher needed. + -- A run once reached segment 182 with all eight badges and then + -- "completed" by skipping the sealed league -- and NOTHING was on + -- disk: no toucher was running, no abandon fired, and route + -- completion never saved. Hours of progress, gone at exit. Never + -- again: the driver banks its own progress as it goes. + if i % 10 == 0 then + saveCheckpoint(i, "rolling (every 10 segments)") + end + + -- GO BACK FOR A MISSING BADGE before the league gate can wall us. + -- + -- Route 22/23's guards badge-check in order and shove the player + -- back (vanilla), and the route only ever moves FORWARD -- so a run + -- that lost a gym segment to a travel failure (Blaine's whole + -- island skipped over a failed sea trip, once) used to reach the + -- gate with seven badges and thrash against the shove until the + -- attempt died. The badges are ordinary inventory; when a league + -- segment comes up short, rewind the index to the missing badge's + -- own gym segment and let the ordinary mismatch->travelTo machinery + -- carry us there. Earliest missing badge first (Cinnabar's segment + -- sits before Viridian's, and Viridian Gym itself only unlocks at + -- seven badges). Once per badge per attempt, so a gym that + -- genuinely cannot be won does not loop the rewind forever. + if LEAGUE_GATE_MAPS[seg.map] then + local missing + for _, bg in ipairs(BADGE_GYM_SEGMENTS) do + if not (G.save.inventory or {})[bg[1]] and not badgeRescued[bg[1]] then + missing = bg + break + end + end + if missing then + local back + for j = i - 1, 1, -1 do + if ROUTE[j].map == missing[2] then back = j break end + end + if back then + badgeRescued[missing[1]] = true + say(("the league gate is ahead but %s is missing -- rewinding " + .. "to segment %d/%d (%s) to earn it") + :format(missing[1], back, #ROUTE, missing[2])) + i = back - 1 + goto nextSegment + end + end + end + -- Standing in an elevator? Ride out to the floor we need, before the -- map-wait can react to a bounce. -- @@ -6694,6 +7014,9 @@ local function runRoute(startIndex) say("---- route complete ----") say(("segments: %d failures: %d"):format(#ROUTE, failures)) + -- bank the final state whatever happened: a "complete" run that + -- skip-cascaded its tail used to exit with nothing on disk + saveCheckpoint(#ROUTE, "route complete") return true end From 20ad4e6c415b3e05bcd7b5b63ed8b25b480ad4d3 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 21 Jul 2026 13:41:34 -0400 Subject: [PATCH 3/5] fix first blue battle, fossil stuff, and another color palette option --- data/palettes_gbc.lua | 1837 ++++++++++++++++++++++++++ data/scripts/oaks_lab.lua | 41 +- data/scripts/story2.lua | 38 +- data/scripts/story3.lua | 2 +- docs/new-features.md | 3 +- src/battle/BattleState.lua | 85 +- src/render/HudTiles.lua | 9 +- src/render/PaletteFX.lua | 92 +- src/script/Commands.lua | 2 +- src/world/OverworldController.lua | 21 +- tests/mod_graphics_tests.lua | 17 + tests/parity_oaks_lab_rival_loss.lua | 67 + tools/extract/palettes.py | 174 ++- tools/rom_manifest.json | 6 + 14 files changed, 2321 insertions(+), 73 deletions(-) create mode 100644 data/palettes_gbc.lua create mode 100644 tests/parity_oaks_lab_rival_loss.lua diff --git a/data/palettes_gbc.lua b/data/palettes_gbc.lua new file mode 100644 index 00000000..452c4c48 --- /dev/null +++ b/data/palettes_gbc.lua @@ -0,0 +1,1837 @@ +-- Generated by tools/build_data.py. DO NOT EDIT. +-- Red++ / pokered-gbc SuperPalettes (COLORS=RED++): 4 8-bit RGB +-- colors per palette (color 0 first), including per-species +-- pals and the GEN_2 MonsterPalettes assignment. +return { + order = { + "TOWNMAP2", + "SLOTS5", + "VIRIDIAN", + "PEWTER", + "CERULEAN", + "LAVENDER", + "VERMILION", + "CELADON", + "FUCHSIA", + "CINNABAR", + "INDIGO", + "SAFFRON", + "TOWNMAP", + "LOGO1", + "LOGO2", + "EXP", + "MEWMON", + "BLUEMON", + "REDMON", + "CYANMON", + "PURPLEMON", + "BROWNMON", + "GREENMON", + "PINKMON", + "YELLOWMON", + "GREYMON", + "SLOTS1", + "SLOTS2", + "SLOTS3", + "SLOTS4", + "BLACK", + "GREENBAR", + "YELLOWBAR", + "REDBAR", + "BADGE", + "CAVE", + "GAMEFREAK", + "BULBASAUR", + "IVYSAUR", + "VENUSAUR", + "CHARMANDER", + "CHARMELEON", + "CHARIZARD", + "SQUIRTLE", + "WARTORTLE", + "BLASTOISE", + "CATERPIE", + "METAPOD", + "BUTTERFREE", + "WEEDLE", + "KAKUNA", + "BEEDRILL", + "PIDGEY", + "PIDGEOTTO", + "PIDGEOT", + "RATTATA", + "RATICATE", + "SPEAROW", + "FEAROW", + "EKANS", + "ARBOK", + "PIKACHU", + "RAICHU", + "SANDSHREW", + "SANDSLASH", + "NIDORAN_F", + "NIDORINA", + "NIDOQUEEN", + "NIDORAN_M", + "NIDORINO", + "NIDOKING", + "CLEFAIRY", + "CLEFABLE", + "VULPIX", + "NINETALES", + "JIGGLYPUFF", + "WIGGLYTUFF", + "ZUBAT", + "GOLBAT", + "ODDISH", + "GLOOM", + "VILEPLUME", + "PARAS", + "PARASECT", + "VENONAT", + "VENOMOTH", + "DIGLETT", + "DUGTRIO", + "MEOWTH", + "PERSIAN", + "PSYDUCK", + "GOLDUCK", + "MANKEY", + "PRIMEAPE", + "GROWLITHE", + "ARCANINE", + "POLIWAG", + "POLIWHIRL", + "POLIWRATH", + "ABRA", + "KADABRA", + "ALAKAZAM", + "MACHOP", + "MACHOKE", + "MACHAMP", + "BELLSPROUT", + "WEEPINBELL", + "VICTREEBEL", + "TENTACOOL", + "TENTACRUEL", + "GEODUDE", + "GRAVELER", + "GOLEM", + "PONYTA", + "RAPIDASH", + "SLOWPOKE", + "SLOWBRO", + "MAGNEMITE", + "MAGNETON", + "FARFETCH_D", + "DODUO", + "DODRIO", + "SEEL", + "DEWGONG", + "GRIMER", + "MUK", + "SHELLDER", + "CLOYSTER", + "GASTLY", + "HAUNTER", + "GENGAR", + "ONIX", + "DROWZEE", + "HYPNO", + "KRABBY", + "KINGLER", + "VOLTORB", + "ELECTRODE", + "EXEGGCUTE", + "EXEGGUTOR", + "CUBONE", + "MAROWAK", + "HITMONLEE", + "HITMONCHAN", + "LICKITUNG", + "KOFFING", + "WEEZING", + "RHYHORN", + "RHYDON", + "CHANSEY", + "TANGELA", + "KANGASKHAN", + "HORSEA", + "SEADRA", + "GOLDEEN", + "SEAKING", + "STARYU", + "STARMIE", + "MR_MIME", + "SCYTHER", + "JYNX", + "ELECTABUZZ", + "MAGMAR", + "PINSIR", + "TAUROS", + "MAGIKARP", + "GYARADOS", + "LAPRAS", + "DITTO", + "EEVEE", + "VAPOREON", + "JOLTEON", + "FLAREON", + "PORYGON", + "OMANYTE", + "OMASTAR", + "KABUTO", + "KABUTOPS", + "AERODACTYL", + "SNORLAX", + "ARTICUNO", + "ZAPDOS", + "MOLTRES", + "DRATINI", + "DRAGONAIR", + "DRAGONITE", + "MEWTWO", + "MEW", + "YOUNGSTER", + "BUGCATCHER", + "LASS", + "SAILOR", + "JR_TRAINER_M", + "JR_TRAINER_F", + "POKEMANIAC", + "SUPERNERD", + "HIKER", + "BIKER", + "BURGLAR", + "ENGINEER", + "JUGGLER", + "FISHERMAN", + "SWIMMER", + "CUEBALL", + "GAMBLER", + "BEAUTY", + "PSYCHIC", + "ROCKER", + "JUGGLER2", + "TAMER", + "BIRDKEEPER", + "BLACKBELT", + "GARY1", + "OAK", + "CHIEF", + "SCIENTIST", + "GIOVANNI", + "ROCKET", + "COOLTRAINER_M", + "COOLTRAINER_F", + "BRUNO", + "BROCK", + "MISTY", + "LT_SURGE", + "ERIKA", + "KOGA", + "BLAINE", + "SABRINA", + "GENTLEMAN", + "GARY2", + "GARY3", + "LORELEI", + "CHANNELER", + "AGATHA", + "LANCE", + "HERO", + "GRAYMON", + "ROUTE", + "PALLET", + }, + palettes = { + ABRA = { + { 255, 255, 255 }, + { 230, 156, 25 }, + { 99, 66, 82 }, + { 0, 0, 0 }, + }, + AERODACTYL = { + { 255, 255, 255 }, + { 173, 123, 148 }, + { 107, 90, 66 }, + { 0, 0, 0 }, + }, + AGATHA = { + { 255, 255, 255 }, + { 222, 181, 140 }, + { 132, 90, 165 }, + { 0, 0, 0 }, + }, + ALAKAZAM = { + { 255, 255, 255 }, + { 230, 156, 25 }, + { 99, 66, 82 }, + { 0, 0, 0 }, + }, + ARBOK = { + { 255, 255, 255 }, + { 148, 66, 156 }, + { 181, 33, 74 }, + { 0, 0, 0 }, + }, + ARCANINE = { + { 255, 255, 255 }, + { 255, 189, 58 }, + { 255, 74, 33 }, + { 0, 0, 0 }, + }, + ARTICUNO = { + { 255, 255, 255 }, + { 90, 173, 255 }, + { 66, 90, 132 }, + { 0, 0, 0 }, + }, + BADGE = { + { 255, 255, 255 }, + { 90, 123, 189 }, + { 33, 33, 165 }, + { 0, 0, 0 }, + }, + BEAUTY = { + { 255, 255, 255 }, + { 197, 140, 90 }, + { 66, 74, 140 }, + { 0, 0, 0 }, + }, + BEEDRILL = { + { 255, 255, 255 }, + { 255, 156, 33 }, + { 247, 33, 8 }, + { 0, 0, 0 }, + }, + BELLSPROUT = { + { 255, 255, 255 }, + { 165, 255, 58 }, + { 222, 82, 74 }, + { 0, 0, 0 }, + }, + BIKER = { + { 255, 255, 255 }, + { 165, 140, 74 }, + { 33, 25, 82 }, + { 0, 0, 0 }, + }, + BIRDKEEPER = { + { 255, 255, 255 }, + { 247, 181, 140 }, + { 49, 99, 214 }, + { 0, 0, 0 }, + }, + BLACK = { + { 255, 255, 255 }, + { 58, 58, 58 }, + { 16, 25, 25 }, + { 0, 0, 0 }, + }, + BLACKBELT = { + { 255, 255, 255 }, + { 214, 123, 66 }, + { 107, 82, 99 }, + { 0, 0, 0 }, + }, + BLAINE = { + { 255, 255, 255 }, + { 247, 165, 132 }, + { 58, 90, 99 }, + { 0, 0, 0 }, + }, + BLASTOISE = { + { 255, 255, 255 }, + { 197, 165, 25 }, + { 66, 90, 255 }, + { 0, 0, 0 }, + }, + BLUEMON = { + { 255, 255, 255 }, + { 132, 148, 255 }, + { 0, 8, 206 }, + { 0, 0, 0 }, + }, + BROCK = { + { 255, 255, 255 }, + { 247, 148, 115 }, + { 115, 58, 82 }, + { 0, 0, 0 }, + }, + BROWNMON = { + { 255, 255, 255 }, + { 239, 148, 82 }, + { 140, 74, 41 }, + { 0, 0, 0 }, + }, + BRUNO = { + { 255, 255, 255 }, + { 206, 140, 99 }, + { 66, 82, 99 }, + { 0, 0, 0 }, + }, + BUGCATCHER = { + { 255, 255, 255 }, + { 222, 140, 82 }, + { 49, 82, 148 }, + { 0, 0, 0 }, + }, + BULBASAUR = { + { 255, 255, 255 }, + { 99, 255, 90 }, + { 255, 82, 49 }, + { 0, 0, 0 }, + }, + BURGLAR = { + { 255, 255, 255 }, + { 230, 132, 74 }, + { 99, 33, 189 }, + { 0, 0, 0 }, + }, + BUTTERFREE = { + { 255, 255, 255 }, + { 148, 206, 255 }, + { 214, 41, 107 }, + { 0, 0, 0 }, + }, + CATERPIE = { + { 255, 255, 255 }, + { 99, 181, 49 }, + { 255, 74, 123 }, + { 0, 0, 0 }, + }, + CAVE = { + { 255, 255, 255 }, + { 189, 66, 0 }, + { 140, 115, 90 }, + { 0, 0, 0 }, + }, + CELADON = { + { 255, 255, 255 }, + { 41, 255, 41 }, + { 90, 189, 255 }, + { 0, 0, 0 }, + }, + CERULEAN = { + { 255, 255, 255 }, + { 41, 66, 255 }, + { 90, 189, 255 }, + { 0, 0, 0 }, + }, + CHANNELER = { + { 255, 255, 255 }, + { 247, 181, 140 }, + { 66, 66, 165 }, + { 0, 0, 0 }, + }, + CHANSEY = { + { 255, 255, 255 }, + { 230, 132, 173 }, + { 255, 74, 156 }, + { 0, 0, 0 }, + }, + CHARIZARD = { + { 255, 255, 255 }, + { 255, 90, 0 }, + { 49, 74, 123 }, + { 0, 0, 0 }, + }, + CHARMANDER = { + { 255, 255, 255 }, + { 255, 107, 33 }, + { 181, 41, 41 }, + { 0, 0, 0 }, + }, + CHARMELEON = { + { 255, 255, 255 }, + { 255, 115, 41 }, + { 189, 74, 82 }, + { 0, 0, 0 }, + }, + CHIEF = { + { 255, 255, 255 }, + { 197, 156, 90 }, + { 82, 107, 140 }, + { 0, 0, 0 }, + }, + CINNABAR = { + { 255, 255, 255 }, + { 255, 66, 66 }, + { 90, 189, 255 }, + { 0, 0, 0 }, + }, + CLEFABLE = { + { 255, 255, 255 }, + { 255, 115, 148 }, + { 107, 58, 0 }, + { 0, 0, 0 }, + }, + CLEFAIRY = { + { 255, 255, 255 }, + { 255, 115, 148 }, + { 107, 58, 0 }, + { 0, 0, 0 }, + }, + CLOYSTER = { + { 255, 255, 255 }, + { 173, 82, 181 }, + { 99, 33, 107 }, + { 0, 0, 0 }, + }, + COOLTRAINER_F = { + { 255, 255, 255 }, + { 214, 148, 123 }, + { 181, 74, 25 }, + { 0, 0, 0 }, + }, + COOLTRAINER_M = { + { 255, 255, 255 }, + { 214, 148, 123 }, + { 181, 74, 25 }, + { 0, 0, 0 }, + }, + CUBONE = { + { 255, 255, 255 }, + { 165, 156, 99 }, + { 132, 82, 33 }, + { 0, 0, 0 }, + }, + CUEBALL = { + { 255, 255, 255 }, + { 230, 181, 148 }, + { 74, 107, 148 }, + { 0, 0, 0 }, + }, + CYANMON = { + { 255, 255, 255 }, + { 99, 197, 181 }, + { 41, 107, 239 }, + { 0, 0, 0 }, + }, + DEWGONG = { + { 255, 255, 255 }, + { 156, 173, 255 }, + { 66, 90, 148 }, + { 0, 0, 0 }, + }, + DIGLETT = { + { 255, 255, 255 }, + { 156, 90, 33 }, + { 197, 49, 33 }, + { 0, 0, 0 }, + }, + DITTO = { + { 255, 255, 255 }, + { 189, 99, 230 }, + { 107, 58, 132 }, + { 0, 0, 0 }, + }, + DODRIO = { + { 255, 255, 255 }, + { 214, 132, 66 }, + { 148, 66, 49 }, + { 0, 0, 0 }, + }, + DODUO = { + { 255, 255, 255 }, + { 173, 107, 74 }, + { 99, 66, 49 }, + { 0, 0, 0 }, + }, + DRAGONAIR = { + { 255, 255, 255 }, + { 115, 156, 255 }, + { 41, 90, 255 }, + { 0, 0, 0 }, + }, + DRAGONITE = { + { 255, 255, 255 }, + { 197, 140, 33 }, + { 90, 82, 140 }, + { 0, 0, 0 }, + }, + DRATINI = { + { 255, 255, 255 }, + { 230, 214, 58 }, + { 41, 90, 197 }, + { 0, 0, 0 }, + }, + DROWZEE = { + { 255, 255, 255 }, + { 255, 189, 33 }, + { 148, 82, 99 }, + { 0, 0, 0 }, + }, + DUGTRIO = { + { 255, 255, 255 }, + { 156, 90, 33 }, + { 197, 49, 33 }, + { 0, 0, 0 }, + }, + EEVEE = { + { 255, 255, 255 }, + { 197, 132, 90 }, + { 140, 82, 66 }, + { 0, 0, 0 }, + }, + EKANS = { + { 255, 255, 255 }, + { 165, 66, 173 }, + { 173, 0, 58 }, + { 0, 0, 0 }, + }, + ELECTABUZZ = { + { 255, 255, 255 }, + { 255, 255, 41 }, + { 197, 132, 41 }, + { 0, 0, 0 }, + }, + ELECTRODE = { + { 255, 255, 255 }, + { 206, 189, 140 }, + { 255, 74, 66 }, + { 0, 0, 0 }, + }, + ENGINEER = { + { 255, 255, 255 }, + { 214, 156, 123 }, + { 239, 197, 74 }, + { 0, 0, 0 }, + }, + ERIKA = { + { 255, 255, 255 }, + { 255, 148, 107 }, + { 58, 123, 66 }, + { 0, 0, 0 }, + }, + EXEGGCUTE = { + { 255, 255, 255 }, + { 255, 123, 140 }, + { 156, 41, 74 }, + { 0, 0, 0 }, + }, + EXEGGUTOR = { + { 255, 255, 255 }, + { 165, 107, 49 }, + { 41, 132, 58 }, + { 0, 0, 0 }, + }, + EXP = { + { 255, 255, 255 }, + { 239, 206, 123 }, + { 33, 140, 255 }, + { 0, 0, 0 }, + }, + FARFETCH_D = { + { 255, 255, 255 }, + { 189, 99, 25 }, + { 49, 214, 8 }, + { 0, 0, 0 }, + }, + FEAROW = { + { 255, 255, 255 }, + { 181, 140, 58 }, + { 255, 90, 0 }, + { 0, 0, 0 }, + }, + FISHERMAN = { + { 255, 255, 255 }, + { 247, 140, 107 }, + { 58, 99, 123 }, + { 0, 0, 0 }, + }, + FLAREON = { + { 255, 255, 255 }, + { 255, 82, 8 }, + { 173, 41, 16 }, + { 0, 0, 0 }, + }, + FUCHSIA = { + { 255, 255, 255 }, + { 255, 123, 123 }, + { 90, 189, 255 }, + { 0, 0, 0 }, + }, + GAMBLER = { + { 255, 255, 255 }, + { 230, 148, 99 }, + { 82, 25, 181 }, + { 0, 0, 0 }, + }, + GAMEFREAK = { + { 255, 255, 255 }, + { 255, 230, 115 }, + { 197, 165, 82 }, + { 0, 0, 0 }, + }, + GARY1 = { + { 255, 255, 255 }, + { 255, 197, 66 }, + { 99, 74, 156 }, + { 0, 0, 0 }, + }, + GARY2 = { + { 255, 255, 255 }, + { 255, 197, 66 }, + { 99, 74, 156 }, + { 0, 0, 0 }, + }, + GARY3 = { + { 255, 255, 255 }, + { 255, 197, 66 }, + { 99, 74, 156 }, + { 0, 0, 0 }, + }, + GASTLY = { + { 255, 255, 255 }, + { 247, 66, 247 }, + { 140, 0, 189 }, + { 0, 0, 0 }, + }, + GENGAR = { + { 255, 255, 255 }, + { 255, 41, 16 }, + { 74, 0, 132 }, + { 0, 0, 0 }, + }, + GENTLEMAN = { + { 255, 255, 255 }, + { 206, 132, 99 }, + { 74, 74, 123 }, + { 0, 0, 0 }, + }, + GEODUDE = { + { 255, 255, 255 }, + { 148, 140, 123 }, + { 66, 90, 58 }, + { 0, 0, 0 }, + }, + GIOVANNI = { + { 255, 255, 255 }, + { 206, 140, 99 }, + { 66, 82, 99 }, + { 0, 0, 0 }, + }, + GLOOM = { + { 255, 255, 255 }, + { 255, 115, 58 }, + { 66, 74, 107 }, + { 0, 0, 0 }, + }, + GOLBAT = { + { 255, 255, 255 }, + { 99, 123, 156 }, + { 41, 66, 140 }, + { 0, 0, 0 }, + }, + GOLDEEN = { + { 255, 255, 255 }, + { 255, 99, 156 }, + { 255, 82, 16 }, + { 0, 0, 0 }, + }, + GOLDUCK = { + { 255, 255, 255 }, + { 181, 173, 214 }, + { 66, 90, 197 }, + { 0, 0, 0 }, + }, + GOLEM = { + { 255, 255, 255 }, + { 148, 140, 123 }, + { 66, 90, 58 }, + { 0, 0, 0 }, + }, + GRAVELER = { + { 255, 255, 255 }, + { 140, 140, 148 }, + { 66, 90, 58 }, + { 0, 0, 0 }, + }, + GRAYMON = { + { 255, 255, 255 }, + { 214, 132, 181 }, + { 123, 107, 148 }, + { 0, 0, 0 }, + }, + GREENBAR = { + { 255, 255, 255 }, + { 239, 206, 123 }, + { 0, 189, 0 }, + { 0, 0, 0 }, + }, + GREENMON = { + { 255, 255, 255 }, + { 140, 255, 90 }, + { 8, 181, 49 }, + { 0, 0, 0 }, + }, + GREYMON = { + { 255, 255, 255 }, + { 214, 132, 181 }, + { 123, 107, 148 }, + { 0, 0, 0 }, + }, + GRIMER = { + { 255, 255, 255 }, + { 239, 16, 165 }, + { 99, 8, 99 }, + { 0, 0, 0 }, + }, + GROWLITHE = { + { 255, 255, 255 }, + { 255, 189, 58 }, + { 255, 74, 33 }, + { 0, 0, 0 }, + }, + GYARADOS = { + { 255, 255, 255 }, + { 222, 206, 58 }, + { 74, 115, 214 }, + { 0, 0, 0 }, + }, + HAUNTER = { + { 255, 255, 255 }, + { 206, 58, 49 }, + { 115, 0, 156 }, + { 0, 0, 0 }, + }, + HERO = { + { 255, 255, 255 }, + { 230, 148, 140 }, + { 181, 58, 66 }, + { 0, 0, 0 }, + }, + HIKER = { + { 255, 255, 255 }, + { 189, 123, 74 }, + { 107, 66, 189 }, + { 0, 0, 0 }, + }, + HITMONCHAN = { + { 255, 255, 255 }, + { 173, 123, 99 }, + { 222, 25, 148 }, + { 0, 0, 0 }, + }, + HITMONLEE = { + { 255, 255, 255 }, + { 181, 115, 41 }, + { 123, 132, 123 }, + { 0, 0, 0 }, + }, + HORSEA = { + { 255, 255, 255 }, + { 230, 222, 99 }, + { 90, 140, 255 }, + { 0, 0, 0 }, + }, + HYPNO = { + { 255, 255, 255 }, + { 255, 206, 0 }, + { 173, 99, 0 }, + { 0, 0, 0 }, + }, + INDIGO = { + { 255, 255, 255 }, + { 90, 66, 255 }, + { 90, 189, 255 }, + { 0, 0, 0 }, + }, + IVYSAUR = { + { 255, 255, 255 }, + { 99, 255, 90 }, + { 255, 99, 140 }, + { 0, 0, 0 }, + }, + JIGGLYPUFF = { + { 255, 255, 255 }, + { 255, 132, 255 }, + { 49, 132, 255 }, + { 0, 0, 0 }, + }, + JOLTEON = { + { 255, 255, 255 }, + { 255, 255, 25 }, + { 230, 90, 8 }, + { 0, 0, 0 }, + }, + JR_TRAINER_F = { + { 255, 255, 255 }, + { 255, 156, 66 }, + { 82, 165, 16 }, + { 0, 0, 0 }, + }, + JR_TRAINER_M = { + { 255, 255, 255 }, + { 255, 156, 66 }, + { 123, 206, 16 }, + { 0, 0, 0 }, + }, + JUGGLER = { + { 255, 255, 255 }, + { 214, 123, 82 }, + { 74, 49, 255 }, + { 0, 0, 0 }, + }, + JUGGLER2 = { + { 255, 255, 255 }, + { 214, 123, 82 }, + { 74, 49, 255 }, + { 0, 0, 0 }, + }, + JYNX = { + { 255, 255, 255 }, + { 255, 222, 33 }, + { 239, 49, 156 }, + { 0, 0, 0 }, + }, + KABUTO = { + { 255, 255, 255 }, + { 189, 123, 90 }, + { 115, 90, 66 }, + { 0, 0, 0 }, + }, + KABUTOPS = { + { 255, 255, 255 }, + { 189, 123, 90 }, + { 115, 90, 66 }, + { 0, 0, 0 }, + }, + KADABRA = { + { 255, 255, 255 }, + { 230, 156, 25 }, + { 99, 66, 82 }, + { 0, 0, 0 }, + }, + KAKUNA = { + { 255, 255, 255 }, + { 214, 206, 25 }, + { 165, 115, 66 }, + { 0, 0, 0 }, + }, + KANGASKHAN = { + { 255, 255, 255 }, + { 156, 115, 33 }, + { 66, 66, 16 }, + { 0, 0, 0 }, + }, + KINGLER = { + { 255, 255, 255 }, + { 239, 140, 74 }, + { 239, 41, 33 }, + { 0, 0, 0 }, + }, + KOFFING = { + { 255, 255, 255 }, + { 206, 82, 206 }, + { 148, 49, 148 }, + { 0, 0, 0 }, + }, + KOGA = { + { 255, 255, 255 }, + { 222, 148, 115 }, + { 99, 58, 107 }, + { 0, 0, 0 }, + }, + KRABBY = { + { 255, 255, 255 }, + { 239, 140, 74 }, + { 239, 41, 33 }, + { 0, 0, 0 }, + }, + LANCE = { + { 255, 255, 255 }, + { 239, 156, 123 }, + { 173, 74, 41 }, + { 0, 0, 0 }, + }, + LAPRAS = { + { 255, 255, 255 }, + { 230, 173, 107 }, + { 66, 132, 230 }, + { 0, 0, 0 }, + }, + LASS = { + { 255, 255, 255 }, + { 206, 140, 49 }, + { 107, 66, 206 }, + { 0, 0, 0 }, + }, + LAVENDER = { + { 255, 255, 255 }, + { 206, 33, 255 }, + { 90, 189, 255 }, + { 0, 0, 0 }, + }, + LICKITUNG = { + { 255, 255, 255 }, + { 255, 82, 148 }, + { 214, 49, 41 }, + { 0, 0, 0 }, + }, + LOGO1 = { + { 255, 255, 255 }, + { 247, 247, 140 }, + { 140, 189, 82 }, + { 173, 0, 33 }, + }, + LOGO2 = { + { 255, 255, 255 }, + { 230, 197, 0 }, + { 148, 156, 148 }, + { 41, 99, 181 }, + }, + LORELEI = { + { 255, 255, 255 }, + { 230, 165, 132 }, + { 206, 49, 49 }, + { 0, 0, 0 }, + }, + LT_SURGE = { + { 255, 255, 255 }, + { 206, 140, 115 }, + { 74, 115, 82 }, + { 0, 0, 0 }, + }, + MACHAMP = { + { 255, 255, 255 }, + { 173, 148, 90 }, + { 74, 90, 33 }, + { 0, 0, 0 }, + }, + MACHOKE = { + { 255, 255, 255 }, + { 132, 140, 90 }, + { 181, 33, 33 }, + { 0, 0, 0 }, + }, + MACHOP = { + { 255, 255, 255 }, + { 165, 140, 90 }, + { 74, 90, 33 }, + { 0, 0, 0 }, + }, + MAGIKARP = { + { 255, 255, 255 }, + { 239, 82, 49 }, + { 148, 25, 74 }, + { 0, 0, 0 }, + }, + MAGMAR = { + { 255, 255, 255 }, + { 255, 165, 0 }, + { 189, 58, 49 }, + { 0, 0, 0 }, + }, + MAGNEMITE = { + { 255, 255, 255 }, + { 90, 165, 255 }, + { 255, 49, 16 }, + { 0, 0, 0 }, + }, + MAGNETON = { + { 255, 255, 255 }, + { 90, 165, 255 }, + { 255, 49, 16 }, + { 0, 0, 0 }, + }, + MANKEY = { + { 255, 255, 255 }, + { 239, 165, 90 }, + { 156, 74, 58 }, + { 0, 0, 0 }, + }, + MAROWAK = { + { 255, 255, 255 }, + { 165, 156, 99 }, + { 132, 82, 33 }, + { 0, 0, 0 }, + }, + MEOWTH = { + { 255, 255, 255 }, + { 255, 222, 107 }, + { 165, 66, 25 }, + { 0, 0, 0 }, + }, + METAPOD = { + { 255, 255, 255 }, + { 123, 255, 0 }, + { 74, 115, 8 }, + { 0, 0, 0 }, + }, + MEW = { + { 255, 255, 255 }, + { 255, 123, 255 }, + { 58, 90, 214 }, + { 0, 0, 0 }, + }, + MEWMON = { + { 255, 255, 255 }, + { 239, 156, 107 }, + { 115, 33, 165 }, + { 0, 0, 0 }, + }, + MEWTWO = { + { 255, 255, 255 }, + { 181, 165, 206 }, + { 132, 41, 165 }, + { 0, 0, 0 }, + }, + MISTY = { + { 255, 255, 255 }, + { 239, 156, 132 }, + { 165, 82, 41 }, + { 0, 0, 0 }, + }, + MOLTRES = { + { 255, 255, 255 }, + { 255, 181, 0 }, + { 255, 99, 25 }, + { 0, 0, 0 }, + }, + MR_MIME = { + { 255, 255, 255 }, + { 255, 90, 255 }, + { 230, 58, 107 }, + { 0, 0, 0 }, + }, + MUK = { + { 255, 255, 255 }, + { 239, 16, 165 }, + { 99, 8, 99 }, + { 0, 0, 0 }, + }, + NIDOKING = { + { 255, 255, 255 }, + { 197, 82, 156 }, + { 107, 25, 123 }, + { 0, 0, 0 }, + }, + NIDOQUEEN = { + { 255, 255, 255 }, + { 222, 156, 58 }, + { 74, 132, 222 }, + { 0, 0, 0 }, + }, + NIDORAN_F = { + { 255, 255, 255 }, + { 148, 173, 247 }, + { 99, 49, 115 }, + { 0, 0, 0 }, + }, + NIDORAN_M = { + { 255, 255, 255 }, + { 222, 140, 181 }, + { 173, 16, 66 }, + { 0, 0, 0 }, + }, + NIDORINA = { + { 255, 255, 255 }, + { 107, 165, 255 }, + { 58, 99, 115 }, + { 0, 0, 0 }, + }, + NIDORINO = { + { 255, 255, 255 }, + { 214, 140, 181 }, + { 173, 16, 66 }, + { 0, 0, 0 }, + }, + NINETALES = { + { 255, 255, 255 }, + { 255, 206, 74 }, + { 148, 123, 0 }, + { 0, 0, 0 }, + }, + OAK = { + { 255, 255, 255 }, + { 197, 156, 90 }, + { 107, 132, 0 }, + { 0, 0, 0 }, + }, + ODDISH = { + { 255, 255, 255 }, + { 90, 189, 25 }, + { 49, 74, 74 }, + { 0, 0, 0 }, + }, + OMANYTE = { + { 255, 255, 255 }, + { 165, 140, 90 }, + { 58, 115, 173 }, + { 0, 0, 0 }, + }, + OMASTAR = { + { 255, 255, 255 }, + { 165, 140, 90 }, + { 58, 115, 173 }, + { 0, 0, 0 }, + }, + ONIX = { + { 255, 255, 255 }, + { 140, 123, 165 }, + { 74, 49, 90 }, + { 0, 0, 0 }, + }, + PALLET = { + { 255, 255, 255 }, + { 148, 148, 123 }, + { 90, 189, 255 }, + { 0, 0, 0 }, + }, + PARAS = { + { 255, 255, 255 }, + { 255, 99, 25 }, + { 189, 25, 0 }, + { 0, 0, 0 }, + }, + PARASECT = { + { 255, 255, 255 }, + { 255, 74, 33 }, + { 115, 41, 8 }, + { 0, 0, 0 }, + }, + PERSIAN = { + { 255, 255, 255 }, + { 255, 230, 82 }, + { 99, 74, 33 }, + { 0, 0, 0 }, + }, + PEWTER = { + { 255, 255, 255 }, + { 148, 148, 123 }, + { 90, 189, 255 }, + { 0, 0, 0 }, + }, + PIDGEOT = { + { 255, 255, 255 }, + { 255, 82, 99 }, + { 156, 66, 16 }, + { 0, 0, 0 }, + }, + PIDGEOTTO = { + { 255, 255, 255 }, + { 255, 82, 99 }, + { 156, 66, 16 }, + { 0, 0, 0 }, + }, + PIDGEY = { + { 255, 255, 255 }, + { 255, 173, 255 }, + { 156, 66, 16 }, + { 0, 0, 0 }, + }, + PIKACHU = { + { 255, 255, 255 }, + { 239, 214, 41 }, + { 214, 49, 0 }, + { 0, 0, 0 }, + }, + PINKMON = { + { 255, 255, 255 }, + { 255, 123, 148 }, + { 255, 0, 49 }, + { 0, 0, 0 }, + }, + PINSIR = { + { 255, 255, 255 }, + { 165, 165, 115 }, + { 140, 82, 58 }, + { 0, 0, 0 }, + }, + POKEMANIAC = { + { 255, 255, 255 }, + { 230, 165, 107 }, + { 107, 41, 148 }, + { 0, 0, 0 }, + }, + POLIWAG = { + { 255, 255, 255 }, + { 214, 66, 41 }, + { 58, 66, 82 }, + { 0, 0, 0 }, + }, + POLIWHIRL = { + { 255, 255, 255 }, + { 132, 132, 214 }, + { 66, 41, 123 }, + { 0, 0, 0 }, + }, + POLIWRATH = { + { 255, 255, 255 }, + { 132, 132, 214 }, + { 49, 49, 123 }, + { 0, 0, 0 }, + }, + PONYTA = { + { 255, 255, 255 }, + { 214, 222, 33 }, + { 255, 41, 25 }, + { 0, 0, 0 }, + }, + PORYGON = { + { 255, 255, 255 }, + { 197, 74, 33 }, + { 99, 90, 206 }, + { 0, 0, 0 }, + }, + PRIMEAPE = { + { 255, 255, 255 }, + { 255, 148, 99 }, + { 132, 66, 41 }, + { 0, 0, 0 }, + }, + PSYCHIC = { + { 255, 255, 255 }, + { 247, 181, 140 }, + { 82, 99, 156 }, + { 0, 0, 0 }, + }, + PSYDUCK = { + { 255, 255, 255 }, + { 255, 222, 33 }, + { 140, 123, 0 }, + { 0, 0, 0 }, + }, + PURPLEMON = { + { 255, 255, 255 }, + { 206, 123, 255 }, + { 156, 0, 181 }, + { 0, 0, 0 }, + }, + RAICHU = { + { 255, 255, 255 }, + { 255, 214, 58 }, + { 255, 99, 0 }, + { 0, 0, 0 }, + }, + RAPIDASH = { + { 255, 255, 255 }, + { 214, 222, 33 }, + { 255, 41, 25 }, + { 0, 0, 0 }, + }, + RATICATE = { + { 255, 255, 255 }, + { 239, 189, 82 }, + { 140, 82, 0 }, + { 0, 0, 0 }, + }, + RATTATA = { + { 255, 255, 255 }, + { 181, 66, 247 }, + { 132, 25, 140 }, + { 0, 0, 0 }, + }, + REDBAR = { + { 255, 255, 255 }, + { 239, 206, 123 }, + { 247, 0, 0 }, + { 0, 0, 0 }, + }, + REDMON = { + { 255, 255, 255 }, + { 255, 140, 0 }, + { 255, 0, 0 }, + { 0, 0, 0 }, + }, + RHYDON = { + { 255, 255, 255 }, + { 123, 90, 140 }, + { 58, 74, 33 }, + { 0, 0, 0 }, + }, + RHYHORN = { + { 255, 255, 255 }, + { 123, 90, 140 }, + { 58, 74, 33 }, + { 0, 0, 0 }, + }, + ROCKER = { + { 255, 255, 255 }, + { 255, 181, 156 }, + { 255, 66, 41 }, + { 0, 0, 0 }, + }, + ROCKET = { + { 255, 255, 255 }, + { 239, 140, 123 }, + { 165, 66, 41 }, + { 0, 0, 0 }, + }, + ROUTE = { + { 255, 255, 255 }, + { 156, 255, 0 }, + { 90, 189, 255 }, + { 0, 0, 0 }, + }, + SABRINA = { + { 255, 255, 255 }, + { 247, 181, 140 }, + { 197, 58, 74 }, + { 0, 0, 0 }, + }, + SAFFRON = { + { 255, 255, 255 }, + { 255, 255, 0 }, + { 90, 189, 255 }, + { 0, 0, 0 }, + }, + SAILOR = { + { 255, 255, 255 }, + { 197, 115, 58 }, + { 25, 58, 107 }, + { 0, 0, 0 }, + }, + SANDSHREW = { + { 255, 255, 255 }, + { 181, 148, 49 }, + { 99, 58, 8 }, + { 0, 0, 0 }, + }, + SANDSLASH = { + { 255, 255, 255 }, + { 189, 115, 33 }, + { 107, 58, 0 }, + { 0, 0, 0 }, + }, + SCIENTIST = { + { 255, 255, 255 }, + { 197, 156, 90 }, + { 82, 107, 140 }, + { 0, 0, 0 }, + }, + SCYTHER = { + { 255, 255, 255 }, + { 123, 214, 0 }, + { 189, 206, 0 }, + { 0, 0, 0 }, + }, + SEADRA = { + { 255, 255, 255 }, + { 230, 181, 132 }, + { 74, 132, 255 }, + { 0, 0, 0 }, + }, + SEAKING = { + { 255, 255, 255 }, + { 156, 181, 247 }, + { 255, 82, 16 }, + { 0, 0, 0 }, + }, + SEEL = { + { 255, 255, 255 }, + { 156, 173, 255 }, + { 239, 74, 107 }, + { 0, 0, 0 }, + }, + SHELLDER = { + { 255, 255, 255 }, + { 132, 66, 107 }, + { 173, 58, 8 }, + { 0, 0, 0 }, + }, + SLOTS1 = { + { 255, 255, 255 }, + { 0, 115, 0 }, + { 16, 165, 25 }, + { 0, 0, 0 }, + }, + SLOTS2 = { + { 255, 255, 255 }, + { 214, 0, 0 }, + { 16, 165, 25 }, + { 0, 0, 0 }, + }, + SLOTS3 = { + { 255, 255, 255 }, + { 66, 33, 181 }, + { 16, 165, 25 }, + { 0, 0, 0 }, + }, + SLOTS4 = { + { 255, 255, 255 }, + { 239, 247, 8 }, + { 16, 165, 25 }, + { 0, 0, 0 }, + }, + SLOTS5 = { + { 255, 255, 255 }, + { 140, 8, 16 }, + { 214, 0, 0 }, + { 0, 0, 0 }, + }, + SLOWBRO = { + { 255, 255, 255 }, + { 255, 82, 140 }, + { 99, 140, 90 }, + { 0, 0, 0 }, + }, + SLOWPOKE = { + { 255, 255, 255 }, + { 255, 82, 140 }, + { 173, 41, 66 }, + { 0, 0, 0 }, + }, + SNORLAX = { + { 255, 255, 255 }, + { 222, 148, 115 }, + { 107, 74, 74 }, + { 0, 0, 0 }, + }, + SPEAROW = { + { 255, 255, 255 }, + { 239, 189, 107 }, + { 173, 66, 90 }, + { 0, 0, 0 }, + }, + SQUIRTLE = { + { 255, 255, 255 }, + { 181, 148, 66 }, + { 99, 156, 255 }, + { 0, 0, 0 }, + }, + STARMIE = { + { 255, 255, 255 }, + { 214, 181, 0 }, + { 156, 58, 148 }, + { 0, 0, 0 }, + }, + STARYU = { + { 255, 255, 255 }, + { 189, 140, 90 }, + { 255, 41, 25 }, + { 0, 0, 0 }, + }, + SUPERNERD = { + { 255, 255, 255 }, + { 247, 181, 140 }, + { 90, 74, 82 }, + { 0, 0, 0 }, + }, + SWIMMER = { + { 255, 255, 255 }, + { 173, 107, 8 }, + { 25, 33, 74 }, + { 0, 0, 0 }, + }, + TAMER = { + { 255, 255, 255 }, + { 230, 165, 123 }, + { 99, 107, 148 }, + { 0, 0, 0 }, + }, + TANGELA = { + { 255, 255, 255 }, + { 74, 107, 189 }, + { 140, 49, 49 }, + { 0, 0, 0 }, + }, + TAUROS = { + { 255, 255, 255 }, + { 206, 123, 41 }, + { 82, 58, 33 }, + { 0, 0, 0 }, + }, + TENTACOOL = { + { 255, 255, 255 }, + { 90, 165, 255 }, + { 247, 41, 82 }, + { 0, 0, 0 }, + }, + TENTACRUEL = { + { 255, 255, 255 }, + { 90, 165, 255 }, + { 214, 16, 16 }, + { 0, 0, 0 }, + }, + TOWNMAP = { + { 255, 255, 255 }, + { 0, 173, 255 }, + { 82, 230, 0 }, + { 0, 0, 0 }, + }, + TOWNMAP2 = { + { 206, 148, 0 }, + { 123, 66, 0 }, + { 0, 0, 0 }, + { 0, 0, 0 }, + }, + VAPOREON = { + { 255, 255, 255 }, + { 132, 181, 255 }, + { 74, 90, 255 }, + { 0, 0, 0 }, + }, + VENOMOTH = { + { 255, 255, 255 }, + { 222, 82, 197 }, + { 99, 41, 115 }, + { 0, 0, 0 }, + }, + VENONAT = { + { 255, 255, 255 }, + { 230, 41, 16 }, + { 90, 0, 115 }, + { 0, 0, 0 }, + }, + VENUSAUR = { + { 255, 255, 255 }, + { 99, 255, 156 }, + { 255, 74, 156 }, + { 0, 0, 0 }, + }, + VERMILION = { + { 255, 255, 255 }, + { 255, 156, 0 }, + { 90, 189, 255 }, + { 0, 0, 0 }, + }, + VICTREEBEL = { + { 255, 255, 255 }, + { 123, 214, 25 }, + { 255, 82, 107 }, + { 0, 0, 0 }, + }, + VILEPLUME = { + { 255, 255, 255 }, + { 255, 49, 25 }, + { 66, 74, 107 }, + { 0, 0, 0 }, + }, + VIRIDIAN = { + { 255, 255, 255 }, + { 156, 255, 0 }, + { 90, 189, 255 }, + { 0, 0, 0 }, + }, + VOLTORB = { + { 255, 255, 255 }, + { 206, 189, 140 }, + { 255, 74, 66 }, + { 0, 0, 0 }, + }, + VULPIX = { + { 255, 255, 255 }, + { 255, 148, 74 }, + { 189, 74, 82 }, + { 0, 0, 0 }, + }, + WARTORTLE = { + { 255, 255, 255 }, + { 181, 148, 66 }, + { 99, 156, 255 }, + { 0, 0, 0 }, + }, + WEEDLE = { + { 255, 255, 255 }, + { 214, 140, 41 }, + { 206, 41, 25 }, + { 0, 0, 0 }, + }, + WEEPINBELL = { + { 255, 255, 255 }, + { 107, 255, 58 }, + { 239, 74, 74 }, + { 0, 0, 0 }, + }, + WEEZING = { + { 255, 255, 255 }, + { 206, 82, 206 }, + { 148, 49, 148 }, + { 0, 0, 0 }, + }, + WIGGLYTUFF = { + { 255, 255, 255 }, + { 255, 132, 255 }, + { 49, 132, 255 }, + { 0, 0, 0 }, + }, + YELLOWBAR = { + { 255, 255, 255 }, + { 239, 206, 123 }, + { 247, 165, 0 }, + { 0, 0, 0 }, + }, + YELLOWMON = { + { 255, 255, 255 }, + { 255, 255, 0 }, + { 230, 115, 0 }, + { 0, 0, 0 }, + }, + YOUNGSTER = { + { 255, 255, 255 }, + { 222, 165, 74 }, + { 74, 99, 189 }, + { 0, 0, 0 }, + }, + ZAPDOS = { + { 255, 255, 255 }, + { 255, 230, 0 }, + { 189, 132, 0 }, + { 0, 0, 0 }, + }, + ZUBAT = { + { 255, 255, 255 }, + { 99, 123, 156 }, + { 41, 66, 140 }, + { 0, 0, 0 }, + }, + }, + pokemon = { + ABRA = "ABRA", + AERODACTYL = "AERODACTYL", + ALAKAZAM = "ALAKAZAM", + ARBOK = "ARBOK", + ARCANINE = "ARCANINE", + ARTICUNO = "ARTICUNO", + BEEDRILL = "BEEDRILL", + BELLSPROUT = "BELLSPROUT", + BLASTOISE = "BLASTOISE", + BULBASAUR = "BULBASAUR", + BUTTERFREE = "BUTTERFREE", + CATERPIE = "CATERPIE", + CHANSEY = "CHANSEY", + CHARIZARD = "CHARIZARD", + CHARMANDER = "CHARMANDER", + CHARMELEON = "CHARMELEON", + CLEFABLE = "CLEFABLE", + CLEFAIRY = "CLEFAIRY", + CLOYSTER = "CLOYSTER", + CUBONE = "CUBONE", + DEWGONG = "DEWGONG", + DIGLETT = "DIGLETT", + DITTO = "DITTO", + DODRIO = "DODRIO", + DODUO = "DODUO", + DRAGONAIR = "DRAGONAIR", + DRAGONITE = "DRAGONITE", + DRATINI = "DRATINI", + DROWZEE = "DROWZEE", + DUGTRIO = "DUGTRIO", + EEVEE = "EEVEE", + EKANS = "EKANS", + ELECTABUZZ = "ELECTABUZZ", + ELECTRODE = "ELECTRODE", + EXEGGCUTE = "EXEGGCUTE", + EXEGGUTOR = "EXEGGUTOR", + FARFETCHD = "FARFETCH_D", + FEAROW = "FEAROW", + FLAREON = "FLAREON", + GASTLY = "GASTLY", + GENGAR = "GENGAR", + GEODUDE = "GEODUDE", + GLOOM = "GLOOM", + GOLBAT = "GOLBAT", + GOLDEEN = "GOLDEEN", + GOLDUCK = "GOLDUCK", + GOLEM = "GOLEM", + GRAVELER = "GRAVELER", + GRIMER = "GRIMER", + GROWLITHE = "GROWLITHE", + GYARADOS = "GYARADOS", + HAUNTER = "HAUNTER", + HITMONCHAN = "HITMONCHAN", + HITMONLEE = "HITMONLEE", + HORSEA = "HORSEA", + HYPNO = "HYPNO", + IVYSAUR = "IVYSAUR", + JIGGLYPUFF = "JIGGLYPUFF", + JOLTEON = "JOLTEON", + JYNX = "JYNX", + KABUTO = "KABUTO", + KABUTOPS = "KABUTOPS", + KADABRA = "KADABRA", + KAKUNA = "KAKUNA", + KANGASKHAN = "KANGASKHAN", + KINGLER = "KINGLER", + KOFFING = "KOFFING", + KRABBY = "KRABBY", + LAPRAS = "LAPRAS", + LICKITUNG = "LICKITUNG", + MACHAMP = "MACHAMP", + MACHOKE = "MACHOKE", + MACHOP = "MACHOP", + MAGIKARP = "MAGIKARP", + MAGMAR = "MAGMAR", + MAGNEMITE = "MAGNEMITE", + MAGNETON = "MAGNETON", + MANKEY = "MANKEY", + MAROWAK = "MAROWAK", + MEOWTH = "MEOWTH", + METAPOD = "METAPOD", + MEW = "MEW", + MEWTWO = "MEWTWO", + MOLTRES = "MOLTRES", + MR_MIME = "MR_MIME", + MUK = "MUK", + NIDOKING = "NIDOKING", + NIDOQUEEN = "NIDOQUEEN", + NIDORAN_F = "NIDORAN_F", + NIDORAN_M = "NIDORAN_M", + NIDORINA = "NIDORINA", + NIDORINO = "NIDORINO", + NINETALES = "NINETALES", + ODDISH = "ODDISH", + OMANYTE = "OMANYTE", + OMASTAR = "OMASTAR", + ONIX = "ONIX", + PARAS = "PARAS", + PARASECT = "PARASECT", + PERSIAN = "PERSIAN", + PIDGEOT = "PIDGEOT", + PIDGEOTTO = "PIDGEOTTO", + PIDGEY = "PIDGEY", + PIKACHU = "PIKACHU", + PINSIR = "PINSIR", + POLIWAG = "POLIWAG", + POLIWHIRL = "POLIWHIRL", + POLIWRATH = "POLIWRATH", + PONYTA = "PONYTA", + PORYGON = "PORYGON", + PRIMEAPE = "PRIMEAPE", + PSYDUCK = "PSYDUCK", + RAICHU = "RAICHU", + RAPIDASH = "RAPIDASH", + RATICATE = "RATICATE", + RATTATA = "RATTATA", + RHYDON = "RHYDON", + RHYHORN = "RHYHORN", + SANDSHREW = "SANDSHREW", + SANDSLASH = "SANDSLASH", + SCYTHER = "SCYTHER", + SEADRA = "SEADRA", + SEAKING = "SEAKING", + SEEL = "SEEL", + SHELLDER = "SHELLDER", + SLOWBRO = "SLOWBRO", + SLOWPOKE = "SLOWPOKE", + SNORLAX = "SNORLAX", + SPEAROW = "SPEAROW", + SQUIRTLE = "SQUIRTLE", + STARMIE = "STARMIE", + STARYU = "STARYU", + TANGELA = "TANGELA", + TAUROS = "TAUROS", + TENTACOOL = "TENTACOOL", + TENTACRUEL = "TENTACRUEL", + VAPOREON = "VAPOREON", + VENOMOTH = "VENOMOTH", + VENONAT = "VENONAT", + VENUSAUR = "VENUSAUR", + VICTREEBEL = "VICTREEBEL", + VILEPLUME = "VILEPLUME", + VOLTORB = "VOLTORB", + VULPIX = "VULPIX", + WARTORTLE = "WARTORTLE", + WEEDLE = "WEEDLE", + WEEPINBELL = "WEEPINBELL", + WEEZING = "WEEZING", + WIGGLYTUFF = "WIGGLYTUFF", + ZAPDOS = "ZAPDOS", + ZUBAT = "ZUBAT", + }, + source = "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm", +} diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index dcce5fef..fb8b0e81 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -10,8 +10,8 @@ -- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party -- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 = -- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm); --- afterwards he gloats or sulks and marches out of the lab --- (OaksLabRivalBattleEndScript). +-- afterwards HealParty + flag always, then he gloats or sulks and +-- marches out (OaksLabRivalEndBattleScript). A loss does not black out. -- ball objects: CHARMANDER (6,3), SQUIRTLE (7,3), BULBASAUR (8,3); -- rival = object 1 at (4,3). rivalBallX is the counter-pick's column. @@ -107,9 +107,9 @@ return { TEXT_OAKSLAB_RIVAL = { { "face_player" }, -- 1 { "check_flag", "EVENT_GOT_STARTER" }, -- 2 - { "jump_if_false", 20 }, -- 3 + { "jump_if_false", 21 }, -- 3 { "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4 - { "jump_if_true", 18 }, -- 5 + { "jump_if_true", 19 }, -- 5 { "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6 { "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7 { "jump_if_false", 11 }, -- 8 @@ -120,18 +120,20 @@ return { { "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur { "jump", 16 }, -- 14 { "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle - { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 16 - { "jump", 21 }, -- 17 - { "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 18 - { "jump", 25 }, -- 19 - { "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 20 - -- battle aftermath: on a win the rival sulks and marches out - -- (OaksLabRivalBattleEnd); on a loss the blackout already warped - -- us away, so the script just ends - { "jump_if_false", 25 }, -- 21 - { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 22 - { "move_npc_to", 1, 4, 11 }, -- 23 - { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 24 (25 = end) + -- OaksLabRivalEndBattleScript: HealParty + flag, then exit either way + { "heal_party" }, -- 16 + { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 17 + { "jump", 23 }, -- 18 + { "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 19 + { "jump", "end" }, -- 20 + { "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 21 + { "jump", "end" }, -- 22 + -- win: sulk text then exit; loss: Rival1WinText already played in + -- battle (HandlePlayerBlackOut), so skip straight to the walk-out + { "jump_if_false", 25 }, -- 23 + { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 24 + { "move_npc_to", 1, 4, 11 }, -- 25 + { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 26 }, }, @@ -178,9 +180,12 @@ return { local party = flags.EVENT_CHOSE_BULBASAUR and 3 or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1 table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party }) + -- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout + table.insert(rows, { "heal_party" }) table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }) - -- a loss blacks out to another map: end the script there - table.insert(rows, { "jump_if_false", base + 7 }) + -- win: sulk text then exit; loss jumps to the walk-out (taunt was + -- already shown in-battle via Rival1WinText) + table.insert(rows, { "jump_if_false", base + 6 }) table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }) table.insert(rows, { "move_npc_to", 1, 4, 11 }) table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }) diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 6823908d..963b8d9a 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -417,6 +417,26 @@ M.BIKE_SHOP = { -- the Cinnabar lab (the wait is skipped). -- ------------------------------------------------------------------- +-- The Super Nerd (object index 1) claims both fossils and blocks the way +-- to them. In scripts/MtMoonB2F.asm he isn't a sight-line trainer: his +-- header carries no range, so MtMoonB2FDefaultScript force-triggers the +-- battle the instant the player steps onto (13,8) -- the chokepoint tile +-- to his right -- and reaching for a fossil intercepts you too. +local function superNerdBeaten(ow) + local nerd = ow:npcByIndex(1) + return not nerd or ow:trainerDefeated(nerd) +end + +local function engageSuperNerd(game, ow, onDone) + local nerd = ow:npcByIndex(1) + if not nerd or ow:trainerDefeated(nerd) then + if onDone then onDone() end + return + end + nerd:facePlayer(ow.player) + ow:engageTrainer(nerd, onDone) +end + local function mtMoonFossil(itemId, otherName) return function(game, ow, npc, done) local TextBox = require("src.render.TextBox") @@ -425,7 +445,13 @@ local function mtMoonFossil(itemId, otherName) game.stack:push(TextBox.new(game, "You already took\na fossil.", done)) return end - game.stack:push(TextBox.new(game, "You found a\nfossil! Take it?", function() + -- can't grab a fossil until the Super Nerd is beaten -- he intercepts + if not superNerdBeaten(ow) then + engageSuperNerd(game, ow, done) + return + end + local name = game.data.items[itemId].name + game.stack:push(TextBox.new(game, ("You want the\n%s?"):format(name), function() game.stack:push(ChoiceBox.new(game, function(yes) if not yes then done() return end game.save.inventory[itemId] = 1 @@ -434,7 +460,6 @@ local function mtMoonFossil(itemId, otherName) local ctx = { save = game.save, overworld = ow, game = game } Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name) Commands.hide_object(ctx, "MT_MOON_B2F", otherName) - local name = game.data.items[itemId].name game.stack:push(TextBox.new(game, ("%s got the\n%s!"):format(game.save.player.name, name), done)) end)) @@ -443,6 +468,15 @@ local function mtMoonFossil(itemId, otherName) end M.MT_MOON_B2F = { + -- MtMoonB2FDefaultScript forces the Super Nerd battle when the player + -- steps onto (13,8), the tile beside him guarding the fossils. + onStep = function(game, ow, x, y) + if x == 13 and y == 8 and not superNerdBeaten(ow) then + engageSuperNerd(game, ow, nil) + return true + end + return false + end, talk = { TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"), TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"), diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index 16e81404..3bd12027 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -104,7 +104,7 @@ M.POKEMON_TOWER_6F = { -- immediately re-fires. ow:scriptMove(ow.player, "right", 1) end - ow:afterBattle(result) + ow:afterBattle(result, battle) end game.stack:push(battle) end)) diff --git a/docs/new-features.md b/docs/new-features.md index 6f6f5436..6155d80f 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -132,6 +132,7 @@ migrated once into `options.lua` on load. - Music / SFX volume - Music Filter - OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules) -- COLORS (GBC / OG / OG INV / GBC INV / CLASSIC), also hotkey `2` +- COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC), also hotkey `2` + (RED++ uses pokered-gbc SuperPalettes + per-species mon colors) - TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming - GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5` \ No newline at end of file diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 3268a5b8..34658437 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -106,19 +106,25 @@ end Assets.register(BattleState.invalidate) --- the species' SGB palette (data/pokemon/palettes.asm), or nil +-- the species' SGB palette (active COLORS pack), or nil local function monPalette(data, species) - local p = data.palettes - local name = p and p.pokemon[species] - local colors = name and p.palettes[name] - return colors and { name = name, colors = colors } or nil + local PaletteFX = require("src.render.PaletteFX") + local colors = PaletteFX.monPal(data, species) + if not colors then return nil end + local name = PaletteFX.monPalName(data, species) + -- prefix so GBC vs RED++ cache keys don't collide on shared names + if PaletteFX.usesGbcPack() then name = "redpp:" .. name end + return { name = name, colors = colors } end --- a named palette from data/generated/palettes.lua as a getImage pal +-- a named palette from the active COLORS pack as a getImage pal local function namedPalette(data, name) - local p = data.palettes - local colors = p and p.palettes[name] - return colors and { name = name, colors = colors } or nil + local PaletteFX = require("src.render.PaletteFX") + local colors = PaletteFX.pal(data, name) + if not colors then return nil end + local key = name + if PaletteFX.usesGbcPack() then key = "redpp:" .. name end + return { name = key, colors = colors } end -- The battle-BGP fade variant of a pic (AnimationFlashScreen and the @@ -264,8 +270,10 @@ function BattleState:speciesSprite(species, isPlayerSide) if not def then return nil end local PaletteFX = require("src.render.PaletteFX") local colors = PaletteFX.monPal(self.data, species, true) + local name = "GRAYMON" + if PaletteFX.usesGbcPack() then name = "redpp:GRAYMON" end return getImage(isPlayerSide and def.spriteBack or def.spriteFront, - colors and { name = "GRAYMON", colors = colors } or nil, + colors and { name = name, colors = colors } or nil, def.trueColor) end @@ -414,6 +422,7 @@ end function BattleState.newTrainer(game, oppClass, partyIndex) local self = newBattle(game) self.kind = "trainer" + self.oppClass = oppClass self.trainer = game.data.trainers[oppClass] assert(self.trainer, "unknown trainer class " .. tostring(oppClass)) self.enemyAIMods = self.trainer.aiMods @@ -2558,6 +2567,22 @@ function BattleState:learnMove(mon, moveId) end) end +-- Map the battle was fought on (overworld wins; save.player.map is fallback). +function BattleState.currentMapId(self) + local game = self.game + local ow = game and game.overworld + if ow and ow.map then return ow.map.id end + local player = game and game.save and game.save.player + return player and player.map +end + +-- pret HandlePlayerBlackOut: OPP_RIVAL1 in OAKS_LAB prints Rival1WinText +-- and returns without blacking out. OaksLabRivalEndBattleScript then heals. +function BattleState.isOaksLabStarterRival(self) + return self.oppClass == "OPP_RIVAL1" + and BattleState.currentMapId(self) == "OAKS_LAB" +end + function BattleState:playerMonFainted() local nextMon = Party.firstHealthy(self.game.save.party) -- Being out of useable POKéMON blacks you out even when the battle was @@ -2570,9 +2595,20 @@ function BattleState:playerMonFainted() -- 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. + -- Exception: the Oak's Lab starter rival (HandlePlayerBlackOut). 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)) + if self.oppClass == "OPP_RIVAL1" then + local TextBox = require("src.render.TextBox") + local raw = (self.data.text and self.data.text._Rival1WinText) + or "{RIVAL}: Yeah! Am\nI great or what?" + self:sayNext(TextBox.substitute(self.game, raw)) + end + -- Oak's Lab starter rival: Rival1WinText only (no blackout lines). + -- Any other wipe, including Route 22 RIVAL1, still blacks out. + if not BattleState.isOaksLabStarterRival(self) 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)) + end self.result = "lose" self.afterQueue = "finish" return @@ -3037,11 +3073,13 @@ function BattleState:finish() 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. + -- healthy in it -- except the Oak's Lab starter rival, where pret skips + -- the blackout and OaksLabRivalEndBattleScript HealParty's immediately. + -- afterBattle only revives and warps to the heal point on a normal + -- "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)) @@ -3364,9 +3402,10 @@ function BattleState:colorMode() if self.colorFxReady == nil then local ready = false local g = love and love.graphics + local PaletteFX = require("src.render.PaletteFX") if g and g.newCanvas and g.setScissor and g.setShader and g.getCanvas - and love.image and self.data.palettes - and require("src.render.PaletteFX").shader() then + and love.image and PaletteFX.pack(self.data) + and PaletteFX.shader() then local ok1, bg = pcall(g.newCanvas, 160, 144) local ok2, wv = pcall(g.newCanvas, 160, 144) if ok1 and ok2 and bg and wv then @@ -3385,9 +3424,10 @@ end -- PAL_MEWMON (= MonsterPalettes[0]) while a side still shows its -- trainer/back pic (the species bytes are 0 then). function BattleState:sgbBattlePals() - local pals = self.data.palettes and self.data.palettes.palettes - if not pals then return nil end local PaletteFX = require("src.render.PaletteFX") + local pack = PaletteFX.pack(self.data) + local pals = pack and pack.palettes + if not pals then return nil end local function bar(b) if not b then return pals.GREENBAR end local hp = b.shownHP or b.mon.hp @@ -3395,8 +3435,7 @@ function BattleState:sgbBattlePals() end local function mon(b, placeholder) if placeholder or not b then return pals.MEWMON or pals.GREENBAR end - local name = self.data.palettes.pokemon[b.mon.species] - return pals[name] or pals.MEWMON + return PaletteFX.monPal(self.data, b.mon.species) or pals.MEWMON end return { [0] = bar(self.player), diff --git a/src/render/HudTiles.lua b/src/render/HudTiles.lua index 6c3299c8..c10fad8f 100644 --- a/src/render/HudTiles.lua +++ b/src/render/HudTiles.lua @@ -86,10 +86,11 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType) px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp)) end local tint - local pals = data.palettes - if pals then - local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR" - local c = pals.palettes[name][3] -- GB color 2 is the fill shade + local PaletteFX = require("src.render.PaletteFX") + local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR" + local colors = PaletteFX.pal(data, name) + if colors then + local c = colors[3] -- GB color 2 is the fill shade -- the fill pixels are the 2/3-gray shade; divide so they land on -- the palette color exactly (the black outline stays black) tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170), diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 54a1575a..445eec20 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -5,17 +5,20 @@ -- then drawn once per zone through a shader that remaps the four DMG -- shades to that zone's palette. -- --- Port display option: COLORS (GBC / OG / OG INV / GBC INV / CLASSIC) +-- Port display option: COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC) -- transforms every zone's palette at send time via effectiveColors. +-- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes +-- (data/palettes_gbc.lua), including per-species mon colors. local PaletteFX = {} local shader -- false = unavailable (headless / no shader support) +local gbcPack -- false = missing; nil = not loaded yet -- Cycle order matches OptionsMenu / hotkey 2 -PaletteFX.MODES = { "gbc", "og", "og_inv", "gbc_inv", "classic" } +PaletteFX.MODES = { "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" } PaletteFX.MODE_LABELS = { - gbc = "GBC", og = "OG", og_inv = "OG INV", + gbc = "GBC", redpp = "RED++", og = "OG", og_inv = "OG INV", gbc_inv = "GBC INV", classic = "CLASSIC", } PaletteFX.mode = "gbc" @@ -124,22 +127,73 @@ function PaletteFX.whole(colors) return PaletteFX.zone(colors, 0, 0, 19, 17) end --- named palette from data/generated/palettes.lua (nil on stale builds) +-- Red++ / pokered-gbc SuperPalette pack (committed; optional if absent). +function PaletteFX.gbcPack() + if gbcPack == nil then + local ok, pack = pcall(require, "data.palettes_gbc") + gbcPack = ok and pack or false + end + return gbcPack or nil +end + +function PaletteFX.usesGbcPack(mode) + mode = mode or PaletteFX.mode + return mode == "redpp" +end + +-- Active named-palette table for COLORS: RED++ uses data/palettes_gbc.lua, +-- everything else uses the ROM-imported data.palettes. +function PaletteFX.pack(data) + if PaletteFX.usesGbcPack() then + local g = PaletteFX.gbcPack() + if g then return g end + end + return data and data.palettes or nil +end + +-- named palette from the active pack (nil on stale builds / missing name). +-- RED++ falls back to the ROM pack for names the gbc table omits (rare). function PaletteFX.pal(data, name) - local p = data.palettes - return p and p.palettes[name] or nil + local p = PaletteFX.pack(data) + local c = p and p.palettes[name] + if c then return c end + if PaletteFX.usesGbcPack() and data and data.palettes then + return data.palettes.palettes[name] + end + return nil end -- the species' palette (data/pokemon/palettes.asm), MEWMON for unknowns. -- transformed forces PAL_GRAYMON (Ditto's palette) regardless of species -- (engine/gfx/palettes.asm DeterminePaletteID: bit TRANSFORMED, a; a -- Transformed mon's pic is tinted gray, not the copied species' own --- SGB color). +-- SGB color). RED++ uses per-species pals from mon_palettes.asm. function PaletteFX.monPal(data, species, transformed) - local p = data.palettes + local p = PaletteFX.pack(data) if not p then return nil end - if transformed then return p.palettes.GRAYMON end - return p.palettes[p.pokemon[species] or "MEWMON"] + if transformed then + return p.palettes.GRAYMON + or (data and data.palettes and data.palettes.palettes.GRAYMON) + end + local name = p.pokemon[species] or "MEWMON" + local c = p.palettes[name] + if c then return c end + if PaletteFX.usesGbcPack() and data and data.palettes then + name = data.palettes.pokemon[species] or "MEWMON" + return data.palettes.palettes[name] + end + return nil +end + +-- palette name a species currently resolves to (for image-cache keys) +function PaletteFX.monPalName(data, species, transformed) + if transformed then return "GRAYMON" end + local p = PaletteFX.pack(data) + if p and p.pokemon[species] then return p.pokemon[species] end + if data and data.palettes and data.palettes.pokemon[species] then + return data.palettes.pokemon[species] + end + return "MEWMON" end -- GetHealthBarColor (home/palettes.asm) on the standard 48px bar @@ -173,13 +227,21 @@ function PaletteFX.permute(colors, map) end function PaletteFX.setMode(mode) + local prev = PaletteFX.mode + local ok = false for _, m in ipairs(PaletteFX.MODES) do if m == mode then PaletteFX.mode = mode - return + ok = true + break end end - PaletteFX.mode = "gbc" + if not ok then PaletteFX.mode = "gbc" end + -- battle pics bake the active pack into ImageData; drop the cache when + -- the pack (or any COLORS mode) changes so the next draw re-tints + if prev ~= PaletteFX.mode then + pcall(function() require("src.battle.BattleState").invalidate() end) + end end function PaletteFX.cycleMode() @@ -188,7 +250,7 @@ function PaletteFX.cycleMode() for i, m in ipairs(PaletteFX.MODES) do if m == cur then idx = i; break end end - PaletteFX.mode = PaletteFX.MODES[idx % #PaletteFX.MODES + 1] + PaletteFX.setMode(PaletteFX.MODES[idx % #PaletteFX.MODES + 1]) return PaletteFX.mode end @@ -202,7 +264,7 @@ end -- When a state exposes no SGB zones but COLORS needs a forced palette -- (OG / OG INV / CLASSIC), invent a whole-screen zone so the shade-remap --- shader still runs. GBC / GBC INV leave nil alone (raw DMG canvas). +-- shader still runs. GBC / RED++ / GBC INV leave nil alone (raw DMG canvas). function PaletteFX.ensureZones(zones) if zones and zones[1] then return zones end local mode = PaletteFX.mode or "gbc" @@ -213,6 +275,8 @@ function PaletteFX.ensureZones(zones) end -- Transform a 4-color palette for the active COLORS display mode. +-- GBC and RED++ pass the zone colors through (RED++ already swapped the +-- pack in pal/monPal); OG* / CLASSIC replace; GBC INV permutes shades. function PaletteFX.effectiveColors(c) if not c then return nil end local mode = PaletteFX.mode or "gbc" diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 3918edff..e12cadc1 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -225,7 +225,7 @@ function Commands.start_battle(ctx, kind, a, b) ctx.lastBattleResult = result ctx.lastCheck = result == "win" if ctx.overworld then - ctx.overworld:afterBattle(result) + ctx.overworld:afterBattle(result, battle) end runner:resume() end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 6e840b25..fb7de260 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1031,7 +1031,7 @@ function OverworldState:goFishing(rod) if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then battle:makeSafari(Game.save.safari) end - battle.onFinish = function(result) self:afterBattle(result) end + battle.onFinish = function(result) self:afterBattle(result, battle) end self:pushBattle(battle) end)) end)) @@ -1687,7 +1687,7 @@ function OverworldState:talkTo(npc) if e == npc then table.remove(self.entities, i) break end end end - self:afterBattle(result) + self:afterBattle(result, battle) unfreeze() end self:pushBattle(battle) @@ -2005,7 +2005,7 @@ function OverworldState:engageTrainer(npc, onDone) end self:checkVictoryRewards(d.trainerClass, d.trainerParty) local after = function() - self:afterBattle(result) + self:afterBattle(result, battle) if onDone then onDone() end end if wonText then @@ -2014,7 +2014,7 @@ function OverworldState:engageTrainer(npc, onDone) after() end else - self:afterBattle(result) + self:afterBattle(result, battle) if onDone then onDone() end end end @@ -2381,7 +2381,7 @@ function OverworldState:onStepComplete() if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then battle:makeSafari(Game.save.safari) end - battle.onFinish = function(result) self:afterBattle(result) end + battle.onFinish = function(result) self:afterBattle(result, battle) end self:pushBattle(battle) return end @@ -2714,7 +2714,9 @@ function OverworldState:safariGameOver(text) end -- Blackouts return to the last heal point; evolutions run after battles. -function OverworldState:afterBattle(result) +-- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the +-- blackout (pret HandlePlayerBlackOut) so the map script can HealParty. +function OverworldState:afterBattle(result, battle) local lead = Game.save.party[1] Logger.info("battle over: %s (lead %s %d/%d)", tostring(result), lead and lead.species or "-", lead and lead.hp or 0, @@ -2724,6 +2726,13 @@ function OverworldState:afterBattle(result) Evolution.checkParty(Game) end if result == "lose" then + local oaksLabRival = battle and battle.oppClass == "OPP_RIVAL1" + and self.map and self.map.id == "OAKS_LAB" + if oaksLabRival then + -- stay in the lab; OaksLabRivalEndBattleScript heals and continues + evolutions() + return + end -- blackout: revive the party at the last heal point; half the -- money is lost (like the original) local Pokemon = require("src.pokemon.Pokemon") diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index 4a119255..f672469e 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -598,6 +598,23 @@ check(PaletteFX.monPal(palData, "TESTMON") == monPalette, check(PaletteFX.monPal(palData, "UNKNOWN") == nil, "an unmapped species falls through to MEWMON (absent here)") +-- RED++ pack: per-species SuperPalettes from data/palettes_gbc.lua +local prevMode = PaletteFX.mode +PaletteFX.setMode("redpp") +check(PaletteFX.usesGbcPack(), "redpp mode selects the gbc pack") +local gbc = PaletteFX.gbcPack() +check(gbc ~= nil and gbc.palettes.BULBASAUR ~= nil, + "data/palettes_gbc.lua ships per-species pals") +check(PaletteFX.monPalName({ palettes = nil }, "BULBASAUR") == "BULBASAUR", + "RED++ monPalName resolves to the species palette id") +check(PaletteFX.monPal({ palettes = nil }, "BULBASAUR") == gbc.palettes.BULBASAUR, + "RED++ monPal reads the species colors without a ROM pack") +check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE, + "RED++ still has ROUTE (aliased from VIRIDIAN)") +check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON, + "RED++ passes zone colors through like GBC") +PaletteFX.setMode(prevMode) + -- ------- the transitions registry local transitions = Registry.new("transitions", Schemas.REGISTRIES.transitions) diff --git a/tests/parity_oaks_lab_rival_loss.lua b/tests/parity_oaks_lab_rival_loss.lua new file mode 100644 index 00000000..d05c69a9 --- /dev/null +++ b/tests/parity_oaks_lab_rival_loss.lua @@ -0,0 +1,67 @@ +-- Parity: losing the Oak's Lab starter rival must not black out. +-- pret HandlePlayerBlackOut special-cases OPP_RIVAL1 on OAKS_LAB: +-- Rival1WinText only, no PlayerBlackedOutText, no warp / half-money. +-- OaksLabRivalEndBattleScript then HealParty and continues either way. +-- Self-contained; run via `luajit tests/parity_oaks_lab_rival_loss.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 oaks lab rival loss") +local check, eq = S.check, S.eq + +local function saidBlackout(b) + for _, m in ipairs(b.said) do + if tostring(m):find("blacked") then return true end + end + return false +end + +local function saidRivalTaunt(b) + for _, m in ipairs(b.said) do + if tostring(m):find("great or what") then return true end + end + return false +end + +local function rivalBattle(mapId) + return { + kind = "trainer", + oppClass = "OPP_RIVAL1", + result = nil, + afterQueue = nil, + said = {}, + data = { + text = { _Rival1WinText = "{RIVAL}: Yeah! Am\nI great or what?" }, + }, + game = { + save = { + party = { { species = "SQUIRTLE", hp = 0, stats = { hp = 20 } } }, + player = { name = "RED", rival = "BLUE", map = mapId }, + }, + overworld = { map = { id = mapId } }, + }, + sayNext = function(self, m) self.said[#self.said + 1] = m end, + say = function(self, m) self.said[#self.said + 1] = m end, + } +end + +do + local b = rivalBattle("OAKS_LAB") + BattleState.playerMonFainted(b) + eq(b.result, "lose", "lab rival wipe is still a loss") + check(saidRivalTaunt(b), "lab rival wipe shows Rival1WinText") + check(not saidBlackout(b), "lab rival wipe does not say blacked out") + check(BattleState.isOaksLabStarterRival(b), "oaks-lab starter-rival detector") +end + +do + local b = rivalBattle("ROUTE_22") + BattleState.playerMonFainted(b) + eq(b.result, "lose", "route-22 rival wipe is a loss") + check(saidRivalTaunt(b), "route-22 rival still shows Rival1WinText") + check(saidBlackout(b), "route-22 rival still blacks out") + check(not BattleState.isOaksLabStarterRival(b), + "route-22 is not the oaks-lab special case") +end + +S.finish() diff --git a/tools/extract/palettes.py b/tools/extract/palettes.py index 385a3209..9be7d694 100644 --- a/tools/extract/palettes.py +++ b/tools/extract/palettes.py @@ -1,6 +1,6 @@ """Extract the Super Game Boy colorization palettes. -Sources: +Sources (vanilla pokered): data/sgb/sgb_palettes.asm SuperPalettes: 4 colors per PAL_* entry, 5-bit RGB (IF DEF(_RED) variants are used; this is a Red port) @@ -8,17 +8,27 @@ Sources: in Pokédex order with species names in the line comments +Sources (pokered-gbc / Red++ SuperPalettes, optional COLORS mode): + data/super_palettes.asm multi-line RGB + ; 0xNN: PAL_NAME comments; + includes per-species pals under GEN_2_GRAPHICS + data/mon_palettes.asm MonsterPalettes: GEN_2 per-species PAL_* ids + Output: data/generated/palettes.lua palettes[NAME] = { {r,g,b} x4 } with 8-bit components, color 0 first pokemon[SPECIES] = NAME (PAL_ prefix stripped) +Output: data/palettes_gbc.lua (committed; not wiped by ROM import) + same shape, plus per-species palette entries (BULBASAUR, …) + The HP bar fill is GB color 2 of PAL_GREENBAR / PAL_YELLOWBAR / PAL_REDBAR; the thresholds live in home/palettes.asm GetHealthBarColor (>= 27 pixels green, >= 10 yellow, else red). """ +import argparse import os import re +import sys from . import util @@ -28,6 +38,11 @@ def _scale5(v): return round(int(v) * 255 / 31) +def _rgb8(nums12): + return [[_scale5(nums12[i]), _scale5(nums12[i + 1]), _scale5(nums12[i + 2])] + for i in range(0, 12, 3)] + + def _read_raw(path): """Raw lines WITH comments (palette / species names live in them).""" with open(path, encoding="utf-8") as f: @@ -54,8 +69,7 @@ def extract(pokered, out_dir): if len(nums) != 12: util.die(f"sgb_palettes.asm:{lineno}: expected 12 components, got {len(nums)}") name = m.group(2) - palettes[name] = [[_scale5(nums[i]), _scale5(nums[i + 1]), _scale5(nums[i + 2])] - for i in range(0, 12, 3)] + palettes[name] = _rgb8(nums) order.append(name) # ---- MonsterPalettes ------------------------------------------------- @@ -91,3 +105,157 @@ def extract(pokered, out_dir): header="SGB colorization: 4 8-bit RGB colors per palette (color 0\n" "first) and the per-species palette assignment.") return palettes, mon_pals + + +def _parse_gbc_super_palettes(path): + """pokered-gbc data/super_palettes.asm: ; 0xNN: PAL_NAME then 4x RGB lines.""" + palettes = {} + order = [] + pending = None + buf = [] + for lineno, line in _read_raw(path): + s = line.strip() + m = re.match(r";\s*0x[0-9a-fA-F]+:\s*PAL_(\w+)", s) + if m: + pending = m.group(1) + buf = [] + continue + m = re.match(r"RGB\s+([\d,\s]+)", s) + if not m or not pending: + continue + nums = [n for n in re.split(r"[,\s]+", m.group(1).strip()) if n] + buf.extend(nums) + if len(buf) < 12: + continue + if len(buf) != 12: + util.die(f"{path}:{lineno}: expected 12 components for PAL_{pending}, " + f"got {len(buf)}") + palettes[pending] = _rgb8(buf) + order.append(pending) + pending = None + buf = [] + if pending: + util.die(f"{path}: incomplete palette PAL_{pending}") + return palettes, order + + +def _parse_gbc_monster_palettes(path): + """GEN_2_GRAPHICS MonsterPalettes paired with ELSE-branch species comments.""" + lines = _read_raw(path) + # species order from the vanilla ELSE comments (dex order + MISSINGNO) + species_order = [] + in_else = False + for _, line in lines: + s = line.strip() + if s.startswith("ELSE"): + in_else = True + continue + if s.startswith("ENDC"): + break + if not in_else: + continue + m = re.match(r"db\s+PAL_\w+\s*;\s*(\w+)", s) + if m: + species_order.append(m.group(1)) + + gen2_pals = [] + in_gen2 = False + for lineno, line in lines: + s = line.strip() + if s.startswith("MonsterPalettes:"): + continue + if s.startswith("IF GEN_2_GRAPHICS"): + in_gen2 = True + continue + if s.startswith("ELSE") or s.startswith("TrainerPalettes:"): + break + if not in_gen2: + continue + m = re.match(r"db\s+PAL_(\w+)", s) + if m: + gen2_pals.append(m.group(1)) + + if len(species_order) != len(gen2_pals): + util.die(f"{path}: GEN_2 MonsterPalettes ({len(gen2_pals)}) != " + f"ELSE species comments ({len(species_order)})") + mon_pals = {} + for species, pal in zip(species_order, gen2_pals): + if species != "MISSINGNO": + mon_pals[species] = pal + return mon_pals + + +def extract_gbc(pokered_gbc, out_path): + """Build the Red++ / pokered-gbc SuperPalette pack used by COLORS=RED++.""" + super_path = os.path.join(pokered_gbc, "data/super_palettes.asm") + mon_path = os.path.join(pokered_gbc, "data/mon_palettes.asm") + if not os.path.isfile(super_path): + util.die(f"missing {super_path}") + if not os.path.isfile(mon_path): + util.die(f"missing {mon_path}") + + palettes, order = _parse_gbc_super_palettes(super_path) + # pret spelling vs Red++ British spelling + if "GREYMON" in palettes and "GRAYMON" not in palettes: + palettes["GRAYMON"] = palettes["GREYMON"] + order.append("GRAYMON") + # Overworld still asks for ROUTE / PALLET; Red++ dropped those SGB + # entries (color/data handles overworld). Alias to closest town pals. + if "ROUTE" not in palettes and "VIRIDIAN" in palettes: + palettes["ROUTE"] = palettes["VIRIDIAN"] + order.append("ROUTE") + if "PALLET" not in palettes and "PEWTER" in palettes: + palettes["PALLET"] = palettes["PEWTER"] + order.append("PALLET") + + mon_pals = _parse_gbc_monster_palettes(mon_path) + if len(mon_pals) != 151: + util.die(f"mon_palettes.asm: parsed {len(mon_pals)} species (want 151)") + for species, pal in mon_pals.items(): + if pal not in palettes: + util.die(f"mon_palettes.asm: unknown palette PAL_{pal} for {species}") + for name in ("MEWMON", "GREENBAR", "YELLOWBAR", "REDBAR", "GRAYMON"): + if name not in palettes: + util.die(f"super_palettes.asm: PAL_{name} missing") + + # write_lua stamps "Generated by tools/build_data.py"; keep that header + # but point the source field at the gbc tree. + util.write_lua( + out_path, + {"source": "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm", + "palettes": palettes, + "order": order, + "pokemon": mon_pals}, + header="Red++ / pokered-gbc SuperPalettes (COLORS=RED++): 4 8-bit RGB\n" + "colors per palette (color 0 first), including per-species\n" + "pals and the GEN_2 MonsterPalettes assignment.") + return palettes, mon_pals + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + sub = parser.add_subparsers(dest="cmd", required=True) + + p_van = sub.add_parser("vanilla", help="extract pret/pokered SGB palettes") + p_van.add_argument("--pokered", required=True) + p_van.add_argument("--out-dir", default="data/generated") + + p_gbc = sub.add_parser("gbc", help="extract pokered-gbc SuperPalettes") + p_gbc.add_argument("--pokered-gbc", required=True) + p_gbc.add_argument("--out", default="data/palettes_gbc.lua") + + args = parser.parse_args(argv) + if args.cmd == "vanilla": + extract(args.pokered, args.out_dir) + else: + extract_gbc(args.pokered_gbc, args.out) + return 0 + + +if __name__ == "__main__": + # python3 -m extract.palettes (from tools/) or python3 tools/extract/palettes.py + if __package__ is None: + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + __package__ = "extract" + from extract import util as util # noqa: F811 + raise SystemExit(main()) diff --git a/tools/rom_manifest.json b/tools/rom_manifest.json index bde4501c..b64064dc 100644 --- a/tools/rom_manifest.json +++ b/tools/rom_manifest.json @@ -42219,6 +42219,12 @@ } }, "MtMoonB2F": { + "1": { + "after": "_MtMoonB2FSuperNerdTheresAPokemonLabText", + "battle": "_MtMoonB2FSuperNerdTheyreBothMineText", + "event": "EVENT_BEAT_MT_MOON_3_SUPER_NERD", + "won": "_MtMoonB2fSuperNerdEachTakeOneText" + }, "2": { "after": "_MtMoonB2FRocket1AfterBattleText", "battle": "_MtMoonB2FRocket1BattleText", From 3f4aaccbf54ce1e20454ee4d249b44fa2b4d81b9 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 21 Jul 2026 13:52:21 -0400 Subject: [PATCH 4/5] attacks should not animate if miss or fail --- src/battle/BattleState.lua | 51 ++++++++++++++++++++++++++++++++++- src/battle/EffectRegistry.lua | 10 +++++++ src/battle/MoveEffects.lua | 5 ++++ tests/run_tests.lua | 32 ++++++++++++++++++++++ 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 34658437..b25167e6 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1556,6 +1556,25 @@ function BattleState:animationsOn() return not o or o.animations ~= false end +-- Drop the announcement-time move-anim row. Gen 1 queues PlayMoveAnimation +-- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips +-- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment +-- and peel it back on miss/fail paths. +function BattleState:cancelMoveAnim() + local row = self.moveAnimRow + if not row then return end + self.moveAnimRow = nil + for i, item in ipairs(self.queue) do + if item == row then + table.remove(self.queue, i) + if self.nextInsert and i <= self.nextInsert then + self.nextInsert = self.nextInsert - 1 + end + return + end + end +end + -- ------------------------------------------------------------------ -- special-effect (SE_*) implementations. Palette effects are BGP -- shade maps ({[i] = shade color index i displays as}); on the SGB the @@ -2160,6 +2179,20 @@ end -- Decomposed into a staged pipeline over the merged move_effects record: -- announcement -> callsMove -> charge -> perform -> primary run -> the -- damaging pipeline (EffectRegistry.runDamaging). + +-- Gen 1 status/stat primary effects call PlayCurrentMoveAnimation only +-- after they land; these failure texts print with no animation. +local function primaryEffectFailed(msgs) + if not msgs or #msgs == 0 then return true end + local m = msgs[1] + if m == "But, it failed!" or m == "Nothing happened!" then return true end + if m:find("didn't affect", 1, true) then return true end + if m:find("is unaffected", 1, true) then return true end + if m:find("protected by MIST", 1, true) then return true end + if m:find("Already", 1, true) then return true end + return false +end + function BattleState:performMove(user, target, moveInst, isCalled) local move = self:moveDef(moveInst) if not move then @@ -2207,6 +2240,11 @@ function BattleState:performMove(user, target, moveInst, isCalled) -- already said its failure text if record and record.callsMove then local pick = record.callsMove(ctx) + -- Mirror Move never plays its own anim (MetronomePickMove does; + -- MirrorMoveCopyMove only reloads the copied move or prints fail) + if move.id == "MIRROR_MOVE" or not pick then + self:cancelMoveAnim() + end if pick then self:performMove(user, target, { id = pick, pp = 1 }, true) end @@ -2249,10 +2287,19 @@ function BattleState:performMove(user, target, moveInst, isCalled) if record.accuracyChecked and (target.invulnerable or not self:accuracyRoll(move, user, target)) then + -- SleepEffect/PoisonEffect/... call PlayCurrentMoveAnimation only + -- after the effect lands; a miss skips it + self:cancelMoveAnim() self:sayNext(("%s's\nattack missed!"):format(displayName(user))) return end - for _, m in ipairs(record.run(ctx)) do + local msgs = record.run(ctx) + -- Gen 1 status/stat effects animate only when they take effect + -- (AlreadyAsleep / NothingHappened / ButItFailed print with no anim) + if primaryEffectFailed(msgs) then + self:cancelMoveAnim() + end + for _, m in ipairs(msgs) do self:sayNext(m) end self:drainNext() -- REST/RECOVER/SOFTBOILED move the user's bar @@ -2260,6 +2307,7 @@ function BattleState:performMove(user, target, moveInst, isCalled) end if move.power == 0 and not (record and record.kind == "full") then MoveEffects.warnUnknown(move.effect) + self:cancelMoveAnim() self:sayNext("But, it failed!") return end @@ -2299,6 +2347,7 @@ function BattleState:continueBide(user, target) local dmg = (user.bideDamage or 0) * 2 user.bideTurns, user.bideDamage = nil, nil if dmg <= 0 then + self:cancelMoveAnim() self:sayNext("But, it failed!") return end diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 2bf023d2..78d55837 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -89,6 +89,8 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- Swift ignores semi-invulnerability (MoveHitTest returns hit for -- SWIFT_EFFECT before the INVULNERABLE check) if target.invulnerable and not neverMiss then + -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) + if not (record and record.explode) then battle:cancelMoveAnim() end battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) return end @@ -97,6 +99,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) if record and record.gate then local ok, failMsg = record.gate(ctx) if not ok then + battle:cancelMoveAnim() if failMsg then battle:sayNext(failMsg) end return end @@ -108,6 +111,8 @@ function EffectRegistry.runDamaging(battle, ctx, record) if not neverMiss then if not battle:accuracyRoll(move, user, target) then + -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) + if not (record and record.explode) then battle:cancelMoveAnim() end battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) -- Jump Kick crash, Explode self-destruct if record and record.onMiss then record.onMiss(ctx, "accuracy") end @@ -134,6 +139,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) end end if not counterable or (battle.lastDamage or 0) == 0 then + battle:cancelMoveAnim() battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) return end @@ -144,6 +150,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- failed with that text already chosen local chosen, extra = record.chooseDamage(ctx) if not chosen then + battle:cancelMoveAnim() if extra then battle:sayNext(extra) end return end @@ -154,12 +161,15 @@ function EffectRegistry.runDamaging(battle, ctx, record) end if info.typeMult == 0 then + -- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim + if not (record and record.explode) then battle:cancelMoveAnim() end battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target))) if record and record.onMiss then record.onMiss(ctx, "immune") end return end if info.missed then -- 0.25x floored the damage to zero: the original registers a miss + if not (record and record.explode) then battle:cancelMoveAnim() end battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) if record and record.onMiss then record.onMiss(ctx, "floored") end return diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 686729ad..183966f0 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -616,6 +616,7 @@ MoveEffects.full = { -- roll is below opponentLevel/4. Teleport's failure text is "But -- it failed!", Roar/Whirlwind's is DidntAffectText; in trainer -- battles Teleport fails and Roar/Whirlwind are "unaffected". + -- Fail paths DelayFrames then print -- no PlayCurrentMoveAnimation. perform = function(ctx) local battle, user, target, move = ctx.battle, ctx.user, ctx.target, ctx.move if battle.kind == "wild" then @@ -635,13 +636,17 @@ MoveEffects.full = { battle.result = "run" battle.afterQueue = "finish" elseif move.id == "TELEPORT" then + battle:cancelMoveAnim() ctx.say("But, it failed!") else + battle:cancelMoveAnim() ctx.say(("It didn't affect\n%s!"):format(displayName(target))) end elseif move.id == "TELEPORT" then + battle:cancelMoveAnim() ctx.say("But, it failed!") else + battle:cancelMoveAnim() ctx.say(("%s\nis unaffected!"):format(displayName(target))) end end, diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 247cee26..ccccc589 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -854,6 +854,11 @@ do ab.rng = mkseq({ 255 }) -- the 1/256 miss ab:performMove(ab.player, ab.enemy, { id = "THUNDER_WAVE", pp = 10 }) eq(ab.enemy.mon.status, nil, "THUNDER WAVE misses on the 255 roll") + local function hasAnim(b) + for _, r in ipairs(b.queue) do if r.anim then return true end end + return false + end + check(not hasAnim(ab), "a missed THUNDER WAVE plays no move animation") ab.rng = mkseq({ 254 }) ab:performMove(ab.player, ab.enemy, { id = "THUNDER_WAVE", pp = 10 }) eq(ab.enemy.mon.status, "PAR", "THUNDER WAVE lands on the 254 roll") @@ -864,6 +869,33 @@ do eq(sbst.player.stages.attack, 1, "SHARPEN skips the accuracy roll") end + -- HandleIfPlayerMoveMissed: skip PlayMoveAnimation on a miss + -- (unless EXPLODE_EFFECT) + do + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) } + local function hasAnim(b) + for _, r in ipairs(b.queue) do if r.anim then return true end end + return false + end + local function sawMiss(b) + for _, r in ipairs(b.queue) do + if r.text and r.text:find("attack missed!", 1, true) then return true end + end + return false + end + local mb = BattleState.newWild(Game, "RATTATA", 5) + mb.rng = function(a, b) return b end -- accuracy 255: miss + mb:performMove(mb.player, mb.enemy, { id = "TACKLE", pp = 10 }) + check(sawMiss(mb), "TACKLE miss prints AttackMissedText") + check(not hasAnim(mb), "a missed TACKLE plays no move animation") + eq(mb.enemy.mon.hp, mb.enemy.mon.stats.hp, "a missed TACKLE deals no damage") + + local hb = BattleState.newWild(Game, "RATTATA", 5) + hb.rng = function(a, b) return a end -- hit + hb:performMove(hb.player, hb.enemy, { id = "TACKLE", pp = 10 }) + check(hasAnim(hb), "a landing TACKLE still queues its move animation") + end + -- #14: EXP.ALL second pass inherits the participant divisor and skips -- fainted mons do From f64666c6ce14f0cb33b75c88f2f92da8e3c53531 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 21 Jul 2026 14:52:45 -0400 Subject: [PATCH 5/5] discord rich presence, and MORE COLORS, video options, shiddddd so much stuff --- data/palettes_gbc.lua | 3843 ++++++++++++++++++++++++- data/scripts/victories.lua | 41 +- main.lua | 3 + src/battle/BattleState.lua | 8 + src/core/Data.lua | 24 + src/core/DiscordPresence.lua | 658 +++++ src/core/Game.lua | 10 +- src/core/GameSpeed.lua | 2 +- src/core/SaveData.lua | 2 + src/core/VideoMode.lua | 54 + src/render/PaletteFX.lua | 126 +- src/render/SpriteRenderer.lua | 65 +- src/render/TileRenderer.lua | 145 +- src/ui/OptionsMenu.lua | 13 +- src/ui/TitleState.lua | 7 +- src/world/Map.lua | 5 +- src/world/MapLoader.lua | 2 +- src/world/NPC.lua | 2 +- src/world/OverworldController.lua | 24 + src/world/Player.lua | 6 +- tests/mod_ui_tests.lua | 6 +- tests/parity_A.lua | 99 + tests/parity_connection_collision.lua | 95 + tests/run_tests.lua | 57 +- tools/extract/palettes.py | 312 +- 25 files changed, 5553 insertions(+), 56 deletions(-) create mode 100644 src/core/DiscordPresence.lua create mode 100644 src/core/VideoMode.lua create mode 100644 tests/parity_connection_collision.lua diff --git a/data/palettes_gbc.lua b/data/palettes_gbc.lua index 452c4c48..5c98c88b 100644 --- a/data/palettes_gbc.lua +++ b/data/palettes_gbc.lua @@ -1,7 +1,10 @@ -- Generated by tools/build_data.py. DO NOT EDIT. +-- extracted from https://github.com/Stewmath/pokered-gbc -- Red++ / pokered-gbc SuperPalettes (COLORS=RED++): 4 8-bit RGB -- colors per palette (color 0 first), including per-species --- pals and the GEN_2 MonsterPalettes assignment. +-- pals and the GEN_2 MonsterPalettes assignment, plus `world` +-- (true overworld tile/roof/sprite GBC coloring -- see this +-- file's module docstring for its shape). return { order = { "TOWNMAP2", @@ -1833,5 +1836,3841 @@ return { ZAPDOS = "ZAPDOS", ZUBAT = "ZUBAT", }, - source = "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm", + source = "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm + color/**", + world = { + groupColors = { + CAVERN = { + { + { 123, 115, 197 }, + { 90, 90, 156 }, + { 58, 58, 99 }, + { 0, 0, 0 }, + }, + { + { 123, 115, 197 }, + { 115, 58, 140 }, + { 107, 0, 66 }, + { 0, 0, 0 }, + }, + { + { 123, 115, 197 }, + { 66, 107, 156 }, + { 0, 90, 107 }, + { 0, 0, 0 }, + }, + { + { 123, 115, 197 }, + { 41, 41, 140 }, + { 25, 25, 82 }, + { 0, 0, 0 }, + }, + { + { 247, 247, 90 }, + { 132, 115, 148 }, + { 132, 115, 82 }, + { 0, 0, 0 }, + }, + { + { 123, 115, 197 }, + { 99, 74, 123 }, + { 66, 33, 41 }, + { 0, 0, 0 }, + }, + { + { 123, 115, 197 }, + { 107, 99, 189 }, + { 90, 74, 165 }, + { 0, 0, 0 }, + }, + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + CEMETERY = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + CLUB = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + DOJO = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + FACILITY = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + FOREST = { + { + { 222, 255, 222 }, + { 173, 173, 173 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 181, 255, 82 }, + { 197, 148, 58 }, + { 165, 123, 25 }, + { 58, 58, 58 }, + }, + { + { 181, 255, 82 }, + { 99, 206, 8 }, + { 41, 115, 0 }, + { 58, 58, 58 }, + }, + { + { 189, 189, 255 }, + { 148, 156, 255 }, + { 107, 99, 255 }, + { 58, 58, 58 }, + }, + { + { 222, 255, 222 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 222, 255, 222 }, + { 197, 148, 58 }, + { 165, 123, 25 }, + { 58, 58, 58 }, + }, + { + { 181, 255, 82 }, + { 165, 255, 115 }, + { 90, 189, 41 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + FOREST_GATE = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + GATE = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + GYM = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + HOUSE = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + INTERIOR = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 156, 82 }, + { 247, 82, 49 }, + { 0, 0, 0 }, + }, + }, + LAB = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 156, 82 }, + { 247, 82, 49 }, + { 0, 0, 0 }, + }, + }, + LOBBY = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 156, 82 }, + { 247, 82, 49 }, + { 0, 0, 0 }, + }, + }, + MANSION = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 156, 82 }, + { 247, 82, 49 }, + { 0, 0, 0 }, + }, + }, + MART = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 156, 82 }, + { 247, 82, 49 }, + { 0, 0, 0 }, + }, + }, + MUSEUM = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 173, 173, 173 }, + { 107, 107, 107 }, + { 0, 0, 0 }, + }, + }, + OVERWORLD = { + { + { 222, 255, 222 }, + { 173, 173, 173 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 222, 255, 222 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 181, 255, 82 }, + { 99, 206, 8 }, + { 41, 115, 0 }, + { 58, 58, 58 }, + }, + { + { 189, 189, 255 }, + { 148, 156, 255 }, + { 107, 99, 255 }, + { 58, 58, 58 }, + }, + { + { 222, 255, 222 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 222, 255, 222 }, + { 197, 148, 58 }, + { 165, 123, 25 }, + { 58, 58, 58 }, + }, + { + { 222, 255, 222 }, + { 165, 255, 115 }, + { 90, 189, 41 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + PLATEAU = { + { + { 222, 255, 222 }, + { 173, 173, 173 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 222, 255, 222 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 181, 255, 82 }, + { 99, 206, 8 }, + { 41, 115, 0 }, + { 58, 58, 58 }, + }, + { + { 189, 189, 255 }, + { 148, 156, 255 }, + { 107, 99, 255 }, + { 58, 58, 58 }, + }, + { + { 222, 255, 222 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 222, 255, 222 }, + { 197, 148, 58 }, + { 165, 123, 25 }, + { 58, 58, 58 }, + }, + { + { 222, 255, 222 }, + { 165, 255, 115 }, + { 90, 189, 41 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + POKECENTER = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 156, 82 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 156, 82 }, + { 247, 82, 49 }, + { 0, 0, 0 }, + }, + }, + REDS_HOUSE_1 = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + REDS_HOUSE_2 = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + SHIP = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + SHIP_PORT = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + UNDERGROUND = { + { + { 247, 230, 214 }, + { 156, 156, 156 }, + { 107, 107, 107 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 156, 197 }, + { 247, 82, 49 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 165, 8 }, + { 74, 107, 0 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 123, 132, 255 }, + { 74, 74, 255 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 255, 255, 58 }, + { 255, 132, 8 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 173, 140, 58 }, + { 132, 107, 25 }, + { 58, 58, 58 }, + }, + { + { 247, 230, 214 }, + { 140, 156, 255 }, + { 115, 132, 255 }, + { 58, 58, 58 }, + }, + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + }, + roofByMapIndex = { + [0] = { + { 255, 255, 255 }, + { 197, 197, 197 }, + }, + [1] = { + { 0, 239, 58 }, + { 0, 197, 58 }, + }, + [2] = { + { 197, 206, 214 }, + { 165, 140, 156 }, + }, + [3] = { + { 115, 197, 255 }, + { 115, 165, 214 }, + }, + [4] = { + { 189, 99, 255 }, + { 156, 74, 197 }, + }, + [5] = { + { 239, 66, 0 }, + { 181, 66, 0 }, + }, + [6] = { + { 123, 214, 156 }, + { 25, 165, 90 }, + }, + [7] = { + { 255, 25, 148 }, + { 206, 25, 99 }, + }, + [8] = { + { 239, 0, 0 }, + { 181, 0, 0 }, + }, + [9] = { + { 132, 0, 255 }, + { 82, 0, 206 }, + }, + [10] = { + { 255, 222, 0 }, + { 230, 181, 0 }, + }, + [11] = { + { 255, 255, 255 }, + { 197, 197, 197 }, + }, + [12] = { + { 255, 255, 255 }, + { 197, 197, 197 }, + }, + [13] = { + { 0, 239, 58 }, + { 0, 197, 58 }, + }, + [14] = { + { 197, 206, 214 }, + { 165, 140, 156 }, + }, + [15] = { + { 197, 206, 214 }, + { 165, 140, 156 }, + }, + [16] = { + { 255, 222, 0 }, + { 230, 181, 0 }, + }, + [17] = { + { 239, 66, 0 }, + { 181, 66, 0 }, + }, + [18] = { + { 255, 222, 0 }, + { 230, 181, 0 }, + }, + [19] = { + { 255, 222, 0 }, + { 230, 181, 0 }, + }, + [20] = { + { 189, 99, 255 }, + { 156, 74, 197 }, + }, + [21] = { + { 189, 99, 255 }, + { 156, 74, 197 }, + }, + [22] = { + { 239, 66, 0 }, + { 181, 66, 0 }, + }, + [23] = { + { 239, 66, 0 }, + { 181, 66, 0 }, + }, + [24] = { + { 255, 25, 148 }, + { 206, 25, 99 }, + }, + [25] = { + { 255, 25, 148 }, + { 206, 25, 99 }, + }, + [26] = { + { 255, 25, 148 }, + { 206, 25, 99 }, + }, + [27] = { + { 123, 214, 156 }, + { 25, 165, 90 }, + }, + [28] = { + { 123, 214, 156 }, + { 25, 165, 90 }, + }, + [29] = { + { 123, 214, 156 }, + { 25, 165, 90 }, + }, + [30] = { + { 239, 0, 0 }, + { 181, 0, 0 }, + }, + [31] = { + { 239, 0, 0 }, + { 181, 0, 0 }, + }, + [32] = { + { 239, 0, 0 }, + { 181, 0, 0 }, + }, + [33] = { + { 132, 0, 255 }, + { 82, 0, 206 }, + }, + [34] = { + { 132, 0, 255 }, + { 82, 0, 206 }, + }, + [35] = { + { 115, 197, 255 }, + { 115, 165, 214 }, + }, + [36] = { + { 115, 197, 255 }, + { 115, 165, 214 }, + }, + }, + roofGroup = { + OVERWORLD = 6, + PLATEAU = 6, + }, + spriteAssignment = { + [0] = 0, + [1] = 1, + [2] = 3, + [3] = "random", + [4] = 0, + [5] = "random", + [6] = "random", + [7] = "random", + [8] = 0, + [9] = "random", + [10] = "random", + [11] = "random", + [12] = "random", + [13] = "random", + [14] = "random", + [15] = 1, + [16] = 1, + [17] = "random", + [18] = "random", + [19] = "random", + [20] = "random", + [21] = 2, + [22] = 1, + [23] = 3, + [24] = "random", + [25] = "random", + [26] = "random", + [27] = "random", + [28] = "random", + [29] = 0, + [30] = 3, + [31] = 3, + [32] = "random", + [33] = "random", + [34] = "random", + [35] = "random", + [36] = "random", + [37] = "random", + [38] = "random", + [39] = "random", + [40] = 0, + [41] = 2, + [42] = "random", + [43] = "random", + [44] = "random", + [45] = "random", + [46] = "random", + [47] = "random", + [48] = "random", + [49] = "random", + [50] = "random", + [51] = "random", + [52] = "random", + [53] = "random", + [54] = "random", + [55] = 0, + [56] = 1, + [57] = 3, + [58] = 0, + [59] = 0, + [60] = 0, + [61] = 0, + [62] = 3, + [63] = 3, + [64] = 0, + [65] = 3, + [66] = 0, + [67] = 3, + [68] = 3, + [69] = "random", + [70] = "random", + [71] = 3, + }, + spritePalettes = { + [0] = { + { 222, 255, 222 }, + { 255, 156, 82 }, + { 255, 58, 8 }, + { 0, 0, 0 }, + }, + [1] = { + { 222, 255, 222 }, + { 255, 156, 82 }, + { 82, 74, 255 }, + { 0, 0, 0 }, + }, + [2] = { + { 222, 255, 222 }, + { 255, 156, 82 }, + { 58, 189, 25 }, + { 0, 0, 0 }, + }, + [3] = { + { 222, 255, 222 }, + { 255, 156, 82 }, + { 123, 82, 25 }, + { 0, 0, 0 }, + }, + [4] = { + { 222, 255, 222 }, + { 255, 156, 82 }, + { 247, 82, 49 }, + { 0, 0, 0 }, + }, + [5] = { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 107, 107, 107 }, + { 0, 0, 0 }, + }, + [6] = { + { 181, 255, 82 }, + { 99, 206, 8 }, + { 41, 115, 0 }, + { 58, 58, 58 }, + }, + [7] = { + { 222, 255, 222 }, + { 197, 148, 58 }, + { 165, 123, 25 }, + { 58, 58, 58 }, + }, + }, + tileGroups = { + CAVERN = { + [0] = 0, + [1] = 5, + [2] = 5, + [3] = 5, + [4] = 5, + [5] = 5, + [6] = 5, + [7] = 5, + [8] = 5, + [9] = 5, + [10] = 5, + [11] = 5, + [12] = 5, + [13] = 5, + [14] = 5, + [15] = 5, + [16] = 5, + [17] = 5, + [18] = 5, + [19] = 5, + [20] = 3, + [21] = 5, + [22] = 5, + [23] = 5, + [24] = 5, + [25] = 5, + [26] = 5, + [27] = 5, + [28] = 5, + [29] = 5, + [30] = 5, + [31] = 5, + [32] = 5, + [33] = 5, + [34] = 0, + [35] = 5, + [36] = 5, + [37] = 5, + [38] = 5, + [39] = 5, + [40] = 5, + [41] = 5, + [42] = 5, + [43] = 5, + [44] = 5, + [45] = 5, + [46] = 5, + [47] = 5, + [48] = 5, + [49] = 5, + [50] = 5, + [51] = 5, + [52] = 5, + [53] = 5, + [54] = 5, + [55] = 5, + [56] = 6, + [57] = 5, + [58] = 5, + [59] = 5, + [60] = 0, + [61] = 5, + [62] = 5, + [63] = 5, + [64] = 5, + [65] = 5, + [66] = 0, + [67] = 0, + [68] = 0, + [69] = 5, + [70] = 0, + [71] = 0, + [72] = 5, + [73] = 0, + [74] = 5, + [75] = 0, + [76] = 5, + [77] = 5, + [78] = 0, + [79] = 0, + [80] = 0, + [81] = 0, + [82] = 0, + [83] = 0, + [84] = 0, + [85] = 0, + [86] = 0, + [87] = 0, + [88] = 0, + [89] = 0, + [90] = 0, + [91] = 0, + [92] = 5, + [93] = 5, + [94] = 0, + [95] = 0, + }, + CEMETERY = { + [0] = 0, + [1] = 0, + [2] = 5, + [3] = 5, + [4] = 5, + [5] = 0, + [6] = 0, + [7] = 5, + [8] = 5, + [9] = 1, + [10] = 1, + [11] = 5, + [12] = 5, + [13] = 5, + [14] = 5, + [15] = 5, + [16] = 0, + [17] = 0, + [18] = 5, + [19] = 5, + [20] = 5, + [21] = 0, + [22] = 0, + [23] = 5, + [24] = 5, + [25] = 1, + [26] = 1, + [27] = 5, + [28] = 5, + [29] = 5, + [30] = 5, + [31] = 5, + [32] = 5, + [33] = 5, + [34] = 6, + [35] = 5, + [36] = 5, + [37] = 5, + [38] = 5, + [39] = 5, + [40] = 0, + [41] = 0, + [42] = 0, + [43] = 0, + [44] = 0, + [45] = 0, + [46] = 0, + [47] = 5, + [48] = 5, + [49] = 5, + [50] = 1, + [51] = 5, + [52] = 5, + [53] = 5, + [54] = 5, + [55] = 5, + [56] = 0, + [57] = 0, + [58] = 5, + [59] = 5, + [60] = 5, + [61] = 5, + [62] = 5, + [63] = 5, + [64] = 0, + [65] = 0, + [66] = 1, + [67] = 0, + [68] = 5, + [69] = 5, + [70] = 5, + [71] = 0, + [72] = 0, + [73] = 0, + [74] = 2, + [75] = 2, + [76] = 2, + [77] = 2, + [78] = 3, + [79] = 5, + [80] = 0, + [81] = 0, + [82] = 1, + [83] = 0, + [84] = 0, + [85] = 0, + [86] = 0, + [87] = 5, + [88] = 0, + [89] = 5, + [90] = 5, + [91] = 5, + [92] = 5, + [93] = 5, + [94] = 0, + [95] = 0, + }, + CLUB = { + [0] = 0, + [1] = 2, + [2] = 2, + [3] = 2, + [4] = 0, + [5] = 5, + [6] = 2, + [7] = 5, + [8] = 5, + [9] = 1, + [10] = 1, + [11] = 1, + [12] = 1, + [13] = 1, + [14] = 1, + [15] = 0, + [16] = 5, + [17] = 2, + [18] = 2, + [19] = 2, + [20] = 0, + [21] = 1, + [22] = 1, + [23] = 5, + [24] = 5, + [25] = 0, + [26] = 1, + [27] = 1, + [28] = 1, + [29] = 1, + [30] = 6, + [31] = 6, + [32] = 0, + [33] = 0, + [34] = 0, + [35] = 0, + [36] = 2, + [37] = 2, + [38] = 2, + [39] = 2, + [40] = 1, + [41] = 1, + [42] = 2, + [43] = 2, + [44] = 1, + [45] = 1, + [46] = 1, + [47] = 1, + [48] = 1, + [49] = 1, + [50] = 1, + [51] = 1, + [52] = 1, + [53] = 1, + [54] = 5, + [55] = 0, + [56] = 0, + [57] = 0, + [58] = 0, + [59] = 0, + [60] = 0, + [61] = 0, + [62] = 0, + [63] = 6, + [64] = 0, + [65] = 0, + [66] = 6, + [67] = 6, + [68] = 6, + [69] = 6, + [70] = 6, + [71] = 5, + [72] = 5, + [73] = 5, + [74] = 5, + [75] = 0, + [76] = 0, + [77] = 0, + [78] = 0, + [79] = 0, + [80] = 0, + [81] = 0, + [82] = 0, + [83] = 0, + [84] = 0, + [85] = 0, + [86] = 0, + [87] = 0, + [88] = 0, + [89] = 0, + [90] = 0, + [91] = 0, + [92] = 0, + [93] = 0, + [94] = 0, + [95] = 0, + }, + DOJO = { + [0] = 6, + [1] = 0, + [2] = 5, + [3] = 1, + [4] = 0, + [5] = 0, + [6] = 1, + [7] = 5, + [8] = 5, + [9] = 0, + [10] = 0, + [11] = 0, + [12] = 0, + [13] = 5, + [14] = 5, + [15] = 0, + [16] = 6, + [17] = 0, + [18] = 5, + [19] = 5, + [20] = 3, + [21] = 0, + [22] = 1, + [23] = 5, + [24] = 5, + [25] = 0, + [26] = 0, + [27] = 0, + [28] = 0, + [29] = 5, + [30] = 5, + [31] = 0, + [32] = 0, + [33] = 0, + [34] = 5, + [35] = 5, + [36] = 6, + [37] = 6, + [38] = 6, + [39] = 6, + [40] = 0, + [41] = 5, + [42] = 5, + [43] = 2, + [44] = 2, + [45] = 2, + [46] = 2, + [47] = 2, + [48] = 0, + [49] = 0, + [50] = 5, + [51] = 5, + [52] = 6, + [53] = 6, + [54] = 0, + [55] = 0, + [56] = 5, + [57] = 5, + [58] = 5, + [59] = 5, + [60] = 1, + [61] = 1, + [62] = 6, + [63] = 1, + [64] = 2, + [65] = 2, + [66] = 6, + [67] = 6, + [68] = 0, + [69] = 0, + [70] = 0, + [71] = 0, + [72] = 0, + [73] = 0, + [74] = 0, + [75] = 0, + [76] = 1, + [77] = 1, + [78] = 5, + [79] = 5, + [80] = 2, + [81] = 2, + [82] = 6, + [83] = 6, + [84] = 0, + [85] = 0, + [86] = 0, + [87] = 0, + [88] = 5, + [89] = 5, + [90] = 5, + [91] = 0, + [92] = 0, + [93] = 0, + [94] = 5, + [95] = 5, + }, + FACILITY = { + [0] = 0, + [1] = 1, + [2] = 5, + [3] = 5, + [4] = 5, + [5] = 2, + [6] = 2, + [7] = 5, + [8] = 4, + [9] = 2, + [10] = 2, + [11] = 5, + [12] = 5, + [13] = 5, + [14] = 5, + [15] = 5, + [16] = 0, + [17] = 0, + [18] = 5, + [19] = 5, + [20] = 3, + [21] = 2, + [22] = 2, + [23] = 5, + [24] = 4, + [25] = 2, + [26] = 2, + [27] = 5, + [28] = 5, + [29] = 5, + [30] = 5, + [31] = 5, + [32] = 1, + [33] = 1, + [34] = 1, + [35] = 5, + [36] = 4, + [37] = 4, + [38] = 5, + [39] = 5, + [40] = 2, + [41] = 2, + [42] = 0, + [43] = 0, + [44] = 0, + [45] = 0, + [46] = 0, + [47] = 5, + [48] = 1, + [49] = 1, + [50] = 1, + [51] = 0, + [52] = 5, + [53] = 5, + [54] = 5, + [55] = 5, + [56] = 2, + [57] = 2, + [58] = 5, + [59] = 5, + [60] = 5, + [61] = 5, + [62] = 5, + [63] = 5, + [64] = 5, + [65] = 5, + [66] = 1, + [67] = 5, + [68] = 5, + [69] = 5, + [70] = 5, + [71] = 5, + [72] = 5, + [73] = 5, + [74] = 5, + [75] = 5, + [76] = 5, + [77] = 5, + [78] = 5, + [79] = 5, + [80] = 5, + [81] = 5, + [82] = 1, + [83] = 5, + [84] = 5, + [85] = 1, + [86] = 0, + [87] = 5, + [88] = 0, + [89] = 5, + [90] = 0, + [91] = 0, + [92] = 5, + [93] = 5, + [94] = 0, + [95] = 0, + }, + FOREST = { + [0] = 2, + [1] = 4, + [2] = 5, + [3] = 5, + [4] = 6, + [5] = 6, + [6] = 6, + [7] = 6, + [8] = 5, + [9] = 5, + [10] = 5, + [11] = 5, + [12] = 5, + [13] = 2, + [14] = 5, + [15] = 5, + [16] = 0, + [17] = 0, + [18] = 5, + [19] = 5, + [20] = 3, + [21] = 6, + [22] = 6, + [23] = 6, + [24] = 5, + [25] = 5, + [26] = 5, + [27] = 5, + [28] = 5, + [29] = 5, + [30] = 5, + [31] = 5, + [32] = 2, + [33] = 5, + [34] = 5, + [35] = 6, + [36] = 6, + [37] = 6, + [38] = 6, + [39] = 6, + [40] = 5, + [41] = 5, + [42] = 5, + [43] = 5, + [44] = 5, + [45] = 5, + [46] = 5, + [47] = 5, + [48] = 2, + [49] = 5, + [50] = 5, + [51] = 0, + [52] = 2, + [53] = 5, + [54] = 5, + [55] = 1, + [56] = 5, + [57] = 2, + [58] = 5, + [59] = 5, + [60] = 5, + [61] = 5, + [62] = 5, + [63] = 5, + [64] = 5, + [65] = 5, + [66] = 5, + [67] = 5, + [68] = 0, + [69] = 0, + [70] = 0, + [71] = 0, + [72] = 5, + [73] = 5, + [74] = 5, + [75] = 5, + [76] = 5, + [77] = 5, + [78] = 5, + [79] = 2, + [80] = 2, + [81] = 2, + [82] = 2, + [83] = 2, + [84] = 2, + [85] = 2, + [86] = 2, + [87] = 2, + [88] = 2, + [89] = 2, + [90] = 2, + [91] = 2, + [92] = 2, + [93] = 2, + [94] = 2, + [95] = 2, + }, + FOREST_GATE = { + [0] = 5, + [1] = 6, + [2] = 0, + [3] = 0, + [4] = 1, + [5] = 2, + [6] = 2, + [7] = 5, + [8] = 5, + [9] = 5, + [10] = 5, + [11] = 5, + [12] = 5, + [13] = 5, + [14] = 5, + [15] = 5, + [16] = 0, + [17] = 0, + [18] = 0, + [19] = 0, + [20] = 1, + [21] = 2, + [22] = 2, + [23] = 5, + [24] = 5, + [25] = 6, + [26] = 5, + [27] = 5, + [28] = 5, + [29] = 5, + [30] = 5, + [31] = 5, + [32] = 5, + [33] = 5, + [34] = 5, + [35] = 5, + [36] = 0, + [37] = 5, + [38] = 5, + [39] = 6, + [40] = 6, + [41] = 5, + [42] = 5, + [43] = 5, + [44] = 6, + [45] = 6, + [46] = 0, + [47] = 0, + [48] = 5, + [49] = 6, + [50] = 5, + [51] = 5, + [52] = 0, + [53] = 5, + [54] = 5, + [55] = 1, + [56] = 1, + [57] = 0, + [58] = 6, + [59] = 5, + [60] = 6, + [61] = 6, + [62] = 0, + [63] = 0, + [64] = 0, + [65] = 0, + [66] = 0, + [67] = 0, + [68] = 0, + [69] = 0, + [70] = 0, + [71] = 0, + [72] = 5, + [73] = 0, + [74] = 5, + [75] = 0, + [76] = 5, + [77] = 5, + [78] = 0, + [79] = 0, + [80] = 0, + [81] = 0, + [82] = 0, + [83] = 0, + [84] = 0, + [85] = 0, + [86] = 0, + [87] = 0, + [88] = 0, + [89] = 0, + [90] = 0, + [91] = 0, + [92] = 5, + [93] = 5, + [94] = 1, + [95] = 0, + }, + GATE = { + [0] = 5, + [1] = 2, + [2] = 0, + [3] = 0, + [4] = 1, + [5] = 2, + [6] = 2, + [7] = 5, + [8] = 5, + [9] = 5, + [10] = 5, + [11] = 5, + [12] = 5, + [13] = 5, + [14] = 5, + [15] = 5, + [16] = 0, + [17] = 0, + [18] = 0, + [19] = 0, + [20] = 1, + [21] = 2, + [22] = 2, + [23] = 5, + [24] = 5, + [25] = 6, + [26] = 5, + [27] = 5, + [28] = 5, + [29] = 5, + [30] = 5, + [31] = 5, + [32] = 5, + [33] = 5, + [34] = 5, + [35] = 5, + [36] = 0, + [37] = 5, + [38] = 5, + [39] = 6, + [40] = 6, + [41] = 5, + [42] = 5, + [43] = 5, + [44] = 6, + [45] = 6, + [46] = 0, + [47] = 0, + [48] = 5, + [49] = 6, + [50] = 5, + [51] = 5, + [52] = 0, + [53] = 5, + [54] = 5, + [55] = 1, + [56] = 1, + [57] = 0, + [58] = 6, + [59] = 5, + [60] = 6, + [61] = 6, + [62] = 0, + [63] = 0, + [64] = 0, + [65] = 0, + [66] = 0, + [67] = 0, + [68] = 0, + [69] = 0, + [70] = 0, + [71] = 0, + [72] = 5, + [73] = 0, + [74] = 5, + [75] = 0, + [76] = 5, + [77] = 5, + [78] = 0, + [79] = 0, + [80] = 0, + [81] = 0, + [82] = 0, + [83] = 0, + [84] = 0, + [85] = 0, + [86] = 0, + [87] = 0, + [88] = 0, + [89] = 0, + [90] = 0, + [91] = 0, + [92] = 5, + [93] = 5, + [94] = 1, + [95] = 0, + }, + GYM = { + [0] = 6, + [1] = 0, + [2] = 5, + [3] = 1, + [4] = 0, + [5] = 0, + [6] = 1, + [7] = 5, + [8] = 5, + [9] = 0, + [10] = 0, + [11] = 0, + [12] = 0, + [13] = 5, + [14] = 5, + [15] = 0, + [16] = 6, + [17] = 0, + [18] = 5, + [19] = 5, + [20] = 3, + [21] = 0, + [22] = 1, + [23] = 5, + [24] = 5, + [25] = 0, + [26] = 0, + [27] = 0, + [28] = 0, + [29] = 5, + [30] = 5, + [31] = 0, + [32] = 0, + [33] = 0, + [34] = 5, + [35] = 5, + [36] = 6, + [37] = 6, + [38] = 6, + [39] = 6, + [40] = 0, + [41] = 5, + [42] = 5, + [43] = 2, + [44] = 2, + [45] = 2, + [46] = 2, + [47] = 2, + [48] = 0, + [49] = 0, + [50] = 5, + [51] = 5, + [52] = 6, + [53] = 6, + [54] = 0, + [55] = 0, + [56] = 5, + [57] = 5, + [58] = 5, + [59] = 5, + [60] = 1, + [61] = 1, + [62] = 6, + [63] = 1, + [64] = 2, + [65] = 2, + [66] = 6, + [67] = 6, + [68] = 0, + [69] = 0, + [70] = 0, + [71] = 0, + [72] = 0, + [73] = 0, + [74] = 0, + [75] = 0, + [76] = 1, + [77] = 1, + [78] = 5, + [79] = 5, + [80] = 2, + [81] = 2, + [82] = 6, + [83] = 6, + [84] = 0, + [85] = 0, + [86] = 0, + [87] = 0, + [88] = 5, + [89] = 5, + [90] = 5, + [91] = 0, + [92] = 0, + [93] = 0, + [94] = 5, + [95] = 5, + }, + HOUSE = { + [0] = 5, + [1] = 0, + [2] = 0, + [3] = 0, + [4] = 1, + [5] = 0, + [6] = 3, + [7] = 3, + [8] = 2, + [9] = 2, + [10] = 2, + [11] = 2, + [12] = 2, + [13] = 2, + [14] = 5, + [15] = 5, + [16] = 0, + [17] = 0, + [18] = 0, + [19] = 0, + [20] = 1, + [21] = 0, + [22] = 3, + [23] = 3, + [24] = 5, + [25] = 5, + [26] = 5, + [27] = 5, + [28] = 0, + [29] = 0, + [30] = 5, + [31] = 5, + [32] = 0, + [33] = 0, + [34] = 3, + [35] = 5, + [36] = 5, + [37] = 5, + [38] = 5, + [39] = 5, + [40] = 5, + [41] = 5, + [42] = 2, + [43] = 2, + [44] = 5, + [45] = 5, + [46] = 5, + [47] = 5, + [48] = 5, + [49] = 5, + [50] = 5, + [51] = 5, + [52] = 5, + [53] = 5, + [54] = 5, + [55] = 5, + [56] = 5, + [57] = 5, + [58] = 5, + [59] = 5, + [60] = 5, + [61] = 5, + [62] = 5, + [63] = 5, + [64] = 0, + [65] = 0, + [66] = 0, + [67] = 0, + [68] = 0, + [69] = 0, + [70] = 5, + [71] = 5, + [72] = 5, + [73] = 5, + [74] = 5, + [75] = 5, + [76] = 5, + [77] = 5, + [78] = 5, + [79] = 5, + [80] = 5, + [81] = 5, + [82] = 5, + [83] = 5, + [84] = 5, + [85] = 5, + [86] = 5, + [87] = 5, + [88] = 5, + [89] = 5, + [90] = 5, + [91] = 5, + [92] = 5, + [93] = 5, + [94] = 5, + [95] = 5, + }, + INTERIOR = { + [0] = 0, + [1] = 2, + [2] = 2, + [3] = 0, + [4] = 0, + [5] = 5, + [6] = 5, + [7] = 2, + [8] = 2, + [9] = 2, + [10] = 2, + [11] = 5, + [12] = 5, + [13] = 5, + [14] = 5, + [15] = 2, + [16] = 2, + [17] = 5, + [18] = 5, + [19] = 1, + [20] = 1, + [21] = 5, + [22] = 5, + [23] = 2, + [24] = 2, + [25] = 2, + [26] = 2, + [27] = 5, + [28] = 5, + [29] = 5, + [30] = 5, + [31] = 0, + [32] = 0, + [33] = 5, + [34] = 5, + [35] = 1, + [36] = 1, + [37] = 0, + [38] = 0, + [39] = 2, + [40] = 2, + [41] = 2, + [42] = 2, + [43] = 0, + [44] = 0, + [45] = 0, + [46] = 0, + [47] = 0, + [48] = 0, + [49] = 1, + [50] = 1, + [51] = 1, + [52] = 0, + [53] = 0, + [54] = 0, + [55] = 2, + [56] = 2, + [57] = 2, + [58] = 2, + [59] = 0, + [60] = 0, + [61] = 0, + [62] = 0, + [63] = 5, + [64] = 5, + [65] = 1, + [66] = 1, + [67] = 1, + [68] = 1, + [69] = 0, + [70] = 1, + [71] = 1, + [72] = 5, + [73] = 5, + [74] = 5, + [75] = 5, + [76] = 5, + [77] = 5, + [78] = 5, + [79] = 5, + [80] = 2, + [81] = 5, + [82] = 5, + [83] = 1, + [84] = 1, + [85] = 1, + [86] = 1, + [87] = 0, + [88] = 5, + [89] = 0, + [90] = 0, + [91] = 5, + [92] = 5, + [93] = 5, + [94] = 5, + [95] = 0, + }, + LAB = { + [0] = 0, + [1] = 6, + [2] = 5, + [3] = 5, + [4] = 5, + [5] = 5, + [6] = 0, + [7] = 0, + [8] = 5, + [9] = 5, + [10] = 5, + [11] = 5, + [12] = 2, + [13] = 2, + [14] = 0, + [15] = 0, + [16] = 5, + [17] = 5, + [18] = 5, + [19] = 5, + [20] = 5, + [21] = 5, + [22] = 0, + [23] = 0, + [24] = 0, + [25] = 0, + [26] = 5, + [27] = 5, + [28] = 6, + [29] = 6, + [30] = 0, + [31] = 0, + [32] = 5, + [33] = 5, + [34] = 5, + [35] = 5, + [36] = 6, + [37] = 6, + [38] = 0, + [39] = 1, + [40] = 5, + [41] = 5, + [42] = 5, + [43] = 5, + [44] = 2, + [45] = 2, + [46] = 5, + [47] = 5, + [48] = 5, + [49] = 5, + [50] = 5, + [51] = 5, + [52] = 1, + [53] = 1, + [54] = 0, + [55] = 1, + [56] = 0, + [57] = 0, + [58] = 5, + [59] = 5, + [60] = 2, + [61] = 2, + [62] = 5, + [63] = 5, + [64] = 5, + [65] = 5, + [66] = 5, + [67] = 5, + [68] = 5, + [69] = 5, + [70] = 5, + [71] = 6, + [72] = 5, + [73] = 5, + [74] = 5, + [75] = 5, + [76] = 1, + [77] = 1, + [78] = 6, + [79] = 0, + [80] = 5, + [81] = 5, + [82] = 5, + [83] = 5, + [84] = 5, + [85] = 5, + [86] = 5, + [87] = 6, + [88] = 6, + [89] = 6, + [90] = 5, + [91] = 5, + [92] = 0, + [93] = 0, + [94] = 0, + [95] = 0, + }, + LOBBY = { + [0] = 0, + [1] = 1, + [2] = 1, + [3] = 1, + [4] = 1, + [5] = 5, + [6] = 1, + [7] = 6, + [8] = 6, + [9] = 5, + [10] = 5, + [11] = 5, + [12] = 5, + [13] = 5, + [14] = 6, + [15] = 6, + [16] = 0, + [17] = 5, + [18] = 1, + [19] = 1, + [20] = 1, + [21] = 5, + [22] = 1, + [23] = 6, + [24] = 6, + [25] = 5, + [26] = 5, + [27] = 5, + [28] = 5, + [29] = 5, + [30] = 6, + [31] = 6, + [32] = 4, + [33] = 1, + [34] = 5, + [35] = 5, + [36] = 5, + [37] = 5, + [38] = 5, + [39] = 5, + [40] = 5, + [41] = 5, + [42] = 0, + [43] = 0, + [44] = 0, + [45] = 0, + [46] = 0, + [47] = 0, + [48] = 5, + [49] = 5, + [50] = 5, + [51] = 5, + [52] = 5, + [53] = 5, + [54] = 5, + [55] = 6, + [56] = 5, + [57] = 5, + [58] = 0, + [59] = 0, + [60] = 0, + [61] = 0, + [62] = 0, + [63] = 0, + [64] = 0, + [65] = 0, + [66] = 0, + [67] = 0, + [68] = 6, + [69] = 0, + [70] = 5, + [71] = 5, + [72] = 2, + [73] = 2, + [74] = 1, + [75] = 5, + [76] = 5, + [77] = 5, + [78] = 5, + [79] = 5, + [80] = 0, + [81] = 0, + [82] = 0, + [83] = 0, + [84] = 6, + [85] = 0, + [86] = 0, + [87] = 0, + [88] = 2, + [89] = 2, + [90] = 5, + [91] = 1, + [92] = 5, + [93] = 5, + [94] = 0, + [95] = 0, + }, + MANSION = { + [0] = 0, + [1] = 5, + [2] = 0, + [3] = 0, + [4] = 1, + [5] = 6, + [6] = 5, + [7] = 5, + [8] = 2, + [9] = 2, + [10] = 5, + [11] = 5, + [12] = 5, + [13] = 5, + [14] = 5, + [15] = 0, + [16] = 0, + [17] = 6, + [18] = 0, + [19] = 0, + [20] = 1, + [21] = 4, + [22] = 5, + [23] = 5, + [24] = 5, + [25] = 5, + [26] = 5, + [27] = 5, + [28] = 5, + [29] = 5, + [30] = 5, + [31] = 0, + [32] = 6, + [33] = 0, + [34] = 5, + [35] = 5, + [36] = 5, + [37] = 5, + [38] = 5, + [39] = 5, + [40] = 4, + [41] = 5, + [42] = 0, + [43] = 0, + [44] = 0, + [45] = 0, + [46] = 0, + [47] = 0, + [48] = 5, + [49] = 0, + [50] = 5, + [51] = 5, + [52] = 5, + [53] = 5, + [54] = 5, + [55] = 5, + [56] = 5, + [57] = 5, + [58] = 5, + [59] = 5, + [60] = 5, + [61] = 0, + [62] = 0, + [63] = 0, + [64] = 5, + [65] = 5, + [66] = 5, + [67] = 5, + [68] = 2, + [69] = 2, + [70] = 5, + [71] = 5, + [72] = 5, + [73] = 5, + [74] = 5, + [75] = 5, + [76] = 5, + [77] = 5, + [78] = 5, + [79] = 5, + [80] = 5, + [81] = 2, + [82] = 2, + [83] = 2, + [84] = 2, + [85] = 5, + [86] = 5, + [87] = 5, + [88] = 5, + [89] = 5, + [90] = 5, + [91] = 5, + [92] = 5, + [93] = 5, + [94] = 5, + [95] = 5, + }, + MART = { + [0] = 0, + [1] = 0, + [2] = 1, + [3] = 1, + [4] = 5, + [5] = 5, + [6] = 0, + [7] = 0, + [8] = 6, + [9] = 0, + [10] = 6, + [11] = 0, + [12] = 1, + [13] = 0, + [14] = 6, + [15] = 6, + [16] = 6, + [17] = 0, + [18] = 1, + [19] = 1, + [20] = 5, + [21] = 5, + [22] = 0, + [23] = 1, + [24] = 5, + [25] = 5, + [26] = 0, + [27] = 0, + [28] = 1, + [29] = 1, + [30] = 6, + [31] = 6, + [32] = 2, + [33] = 2, + [34] = 5, + [35] = 5, + [36] = 1, + [37] = 7, + [38] = 1, + [39] = 1, + [40] = 5, + [41] = 6, + [42] = 1, + [43] = 1, + [44] = 6, + [45] = 6, + [46] = 6, + [47] = 6, + [48] = 2, + [49] = 2, + [50] = 5, + [51] = 5, + [52] = 1, + [53] = 7, + [54] = 0, + [55] = 1, + [56] = 6, + [57] = 0, + [58] = 0, + [59] = 0, + [60] = 0, + [61] = 0, + [62] = 6, + [63] = 6, + [64] = 0, + [65] = 0, + [66] = 0, + [67] = 0, + [68] = 0, + [69] = 0, + [70] = 0, + [71] = 0, + [72] = 0, + [73] = 0, + [74] = 0, + [75] = 0, + [76] = 6, + [77] = 6, + [78] = 4, + [79] = 4, + [80] = 0, + [81] = 0, + [82] = 0, + [83] = 0, + [84] = 0, + [85] = 0, + [86] = 0, + [87] = 0, + [88] = 0, + [89] = 5, + [90] = 5, + [91] = 5, + [92] = 0, + [93] = 0, + [94] = 0, + [95] = 0, + }, + MUSEUM = { + [0] = 5, + [1] = 1, + [2] = 0, + [3] = 0, + [4] = 1, + [5] = 2, + [6] = 2, + [7] = 5, + [8] = 5, + [9] = 5, + [10] = 5, + [11] = 5, + [12] = 5, + [13] = 5, + [14] = 5, + [15] = 5, + [16] = 0, + [17] = 0, + [18] = 0, + [19] = 0, + [20] = 1, + [21] = 2, + [22] = 2, + [23] = 5, + [24] = 5, + [25] = 6, + [26] = 5, + [27] = 5, + [28] = 5, + [29] = 5, + [30] = 5, + [31] = 5, + [32] = 5, + [33] = 5, + [34] = 5, + [35] = 5, + [36] = 0, + [37] = 5, + [38] = 5, + [39] = 6, + [40] = 6, + [41] = 5, + [42] = 5, + [43] = 5, + [44] = 6, + [45] = 6, + [46] = 6, + [47] = 6, + [48] = 6, + [49] = 6, + [50] = 5, + [51] = 5, + [52] = 0, + [53] = 5, + [54] = 5, + [55] = 1, + [56] = 1, + [57] = 0, + [58] = 6, + [59] = 5, + [60] = 6, + [61] = 6, + [62] = 0, + [63] = 0, + [64] = 0, + [65] = 0, + [66] = 0, + [67] = 0, + [68] = 0, + [69] = 0, + [70] = 6, + [71] = 6, + [72] = 5, + [73] = 0, + [74] = 5, + [75] = 0, + [76] = 5, + [77] = 5, + [78] = 0, + [79] = 0, + [80] = 0, + [81] = 0, + [82] = 0, + [83] = 0, + [84] = 0, + [85] = 0, + [86] = 0, + [87] = 0, + [88] = 0, + [89] = 0, + [90] = 0, + [91] = 0, + [92] = 5, + [93] = 5, + [94] = 1, + [95] = 0, + }, + OVERWORLD = { + [0] = 0, + [1] = 5, + [2] = 5, + [3] = 1, + [4] = 5, + [5] = 6, + [6] = 6, + [7] = 6, + [8] = 6, + [9] = 6, + [10] = 4, + [11] = 4, + [12] = 4, + [13] = 0, + [14] = 5, + [15] = 0, + [16] = 0, + [17] = 5, + [18] = 6, + [19] = 5, + [20] = 3, + [21] = 6, + [22] = 6, + [23] = 6, + [24] = 6, + [25] = 6, + [26] = 0, + [27] = 5, + [28] = 5, + [29] = 2, + [30] = 5, + [31] = 0, + [32] = 0, + [33] = 0, + [34] = 0, + [35] = 0, + [36] = 5, + [37] = 6, + [38] = 6, + [39] = 5, + [40] = 6, + [41] = 6, + [42] = 0, + [43] = 0, + [44] = 2, + [45] = 2, + [46] = 2, + [47] = 0, + [48] = 2, + [49] = 0, + [50] = 5, + [51] = 5, + [52] = 5, + [53] = 5, + [54] = 5, + [55] = 5, + [56] = 6, + [57] = 0, + [58] = 0, + [59] = 0, + [60] = 5, + [61] = 2, + [62] = 2, + [63] = 0, + [64] = 2, + [65] = 2, + [66] = 0, + [67] = 0, + [68] = 0, + [69] = 0, + [70] = 0, + [71] = 0, + [72] = 5, + [73] = 5, + [74] = 0, + [75] = 5, + [76] = 6, + [77] = 6, + [78] = 0, + [79] = 0, + [80] = 2, + [81] = 2, + [82] = 2, + [83] = 6, + [84] = 5, + [85] = 5, + [86] = 0, + [87] = 0, + [88] = 5, + [89] = 5, + [90] = 6, + [91] = 5, + [92] = 6, + [93] = 6, + [94] = 0, + [95] = 0, + }, + PLATEAU = { + [0] = 0, + [1] = 5, + [2] = 5, + [3] = 0, + [4] = 0, + [5] = 5, + [6] = 5, + [7] = 2, + [8] = 2, + [9] = 0, + [10] = 0, + [11] = 1, + [12] = 1, + [13] = 5, + [14] = 5, + [15] = 5, + [16] = 5, + [17] = 5, + [18] = 5, + [19] = 5, + [20] = 3, + [21] = 5, + [22] = 5, + [23] = 2, + [24] = 2, + [25] = 0, + [26] = 0, + [27] = 1, + [28] = 1, + [29] = 5, + [30] = 5, + [31] = 5, + [32] = 0, + [33] = 0, + [34] = 5, + [35] = 2, + [36] = 5, + [37] = 5, + [38] = 5, + [39] = 5, + [40] = 5, + [41] = 5, + [42] = 5, + [43] = 5, + [44] = 2, + [45] = 2, + [46] = 0, + [47] = 0, + [48] = 5, + [49] = 5, + [50] = 5, + [51] = 5, + [52] = 5, + [53] = 5, + [54] = 5, + [55] = 5, + [56] = 0, + [57] = 5, + [58] = 5, + [59] = 5, + [60] = 5, + [61] = 6, + [62] = 6, + [63] = 6, + [64] = 6, + [65] = 6, + [66] = 6, + [67] = 6, + [68] = 6, + [69] = 2, + [70] = 0, + [71] = 0, + [72] = 0, + [73] = 0, + [74] = 0, + [75] = 0, + [76] = 0, + [77] = 0, + [78] = 0, + [79] = 0, + [80] = 0, + [81] = 0, + [82] = 0, + [83] = 0, + [84] = 0, + [85] = 0, + [86] = 0, + [87] = 0, + [88] = 0, + [89] = 0, + [90] = 0, + [91] = 0, + [92] = 0, + [93] = 0, + [94] = 0, + [95] = 0, + }, + POKECENTER = { + [0] = 0, + [1] = 0, + [2] = 1, + [3] = 1, + [4] = 5, + [5] = 5, + [6] = 0, + [7] = 0, + [8] = 6, + [9] = 0, + [10] = 6, + [11] = 0, + [12] = 1, + [13] = 0, + [14] = 6, + [15] = 6, + [16] = 6, + [17] = 0, + [18] = 1, + [19] = 1, + [20] = 5, + [21] = 5, + [22] = 0, + [23] = 1, + [24] = 5, + [25] = 5, + [26] = 0, + [27] = 0, + [28] = 1, + [29] = 1, + [30] = 6, + [31] = 6, + [32] = 2, + [33] = 2, + [34] = 5, + [35] = 5, + [36] = 1, + [37] = 4, + [38] = 1, + [39] = 1, + [40] = 5, + [41] = 6, + [42] = 1, + [43] = 1, + [44] = 6, + [45] = 6, + [46] = 6, + [47] = 6, + [48] = 2, + [49] = 2, + [50] = 5, + [51] = 5, + [52] = 1, + [53] = 4, + [54] = 0, + [55] = 1, + [56] = 6, + [57] = 0, + [58] = 0, + [59] = 0, + [60] = 0, + [61] = 0, + [62] = 6, + [63] = 6, + [64] = 0, + [65] = 0, + [66] = 0, + [67] = 0, + [68] = 0, + [69] = 0, + [70] = 0, + [71] = 0, + [72] = 0, + [73] = 0, + [74] = 0, + [75] = 0, + [76] = 6, + [77] = 6, + [78] = 4, + [79] = 4, + [80] = 0, + [81] = 0, + [82] = 0, + [83] = 0, + [84] = 0, + [85] = 0, + [86] = 0, + [87] = 0, + [88] = 0, + [89] = 5, + [90] = 5, + [91] = 5, + [92] = 0, + [93] = 0, + [94] = 0, + [95] = 0, + }, + REDS_HOUSE_1 = { + [0] = 5, + [1] = 0, + [2] = 0, + [3] = 0, + [4] = 1, + [5] = 0, + [6] = 3, + [7] = 3, + [8] = 2, + [9] = 2, + [10] = 5, + [11] = 5, + [12] = 5, + [13] = 5, + [14] = 0, + [15] = 0, + [16] = 0, + [17] = 0, + [18] = 0, + [19] = 0, + [20] = 1, + [21] = 0, + [22] = 3, + [23] = 3, + [24] = 5, + [25] = 5, + [26] = 5, + [27] = 5, + [28] = 5, + [29] = 5, + [30] = 0, + [31] = 0, + [32] = 0, + [33] = 0, + [34] = 5, + [35] = 5, + [36] = 5, + [37] = 5, + [38] = 5, + [39] = 5, + [40] = 5, + [41] = 5, + [42] = 5, + [43] = 5, + [44] = 5, + [45] = 0, + [46] = 0, + [47] = 0, + [48] = 5, + [49] = 5, + [50] = 5, + [51] = 5, + [52] = 5, + [53] = 5, + [54] = 5, + [55] = 5, + [56] = 5, + [57] = 5, + [58] = 5, + [59] = 5, + [60] = 5, + [61] = 0, + [62] = 0, + [63] = 0, + [64] = 0, + [65] = 0, + [66] = 0, + [67] = 0, + [68] = 2, + [69] = 2, + [70] = 5, + [71] = 5, + [72] = 0, + [73] = 0, + [74] = 0, + [75] = 0, + [76] = 0, + [77] = 0, + [78] = 0, + [79] = 0, + [80] = 0, + [81] = 0, + [82] = 0, + [83] = 0, + [84] = 0, + [85] = 0, + [86] = 0, + [87] = 0, + [88] = 0, + [89] = 0, + [90] = 0, + [91] = 0, + [92] = 5, + [93] = 0, + [94] = 0, + [95] = 0, + }, + REDS_HOUSE_2 = { + [0] = 5, + [1] = 0, + [2] = 0, + [3] = 0, + [4] = 1, + [5] = 0, + [6] = 3, + [7] = 3, + [8] = 2, + [9] = 2, + [10] = 5, + [11] = 5, + [12] = 5, + [13] = 5, + [14] = 0, + [15] = 0, + [16] = 0, + [17] = 0, + [18] = 0, + [19] = 0, + [20] = 1, + [21] = 0, + [22] = 3, + [23] = 3, + [24] = 5, + [25] = 5, + [26] = 5, + [27] = 5, + [28] = 5, + [29] = 5, + [30] = 0, + [31] = 0, + [32] = 0, + [33] = 0, + [34] = 5, + [35] = 5, + [36] = 5, + [37] = 5, + [38] = 5, + [39] = 5, + [40] = 5, + [41] = 5, + [42] = 5, + [43] = 5, + [44] = 5, + [45] = 0, + [46] = 0, + [47] = 0, + [48] = 5, + [49] = 5, + [50] = 5, + [51] = 5, + [52] = 5, + [53] = 5, + [54] = 5, + [55] = 5, + [56] = 5, + [57] = 5, + [58] = 5, + [59] = 5, + [60] = 5, + [61] = 0, + [62] = 0, + [63] = 0, + [64] = 0, + [65] = 0, + [66] = 0, + [67] = 0, + [68] = 2, + [69] = 2, + [70] = 5, + [71] = 5, + [72] = 0, + [73] = 0, + [74] = 0, + [75] = 0, + [76] = 0, + [77] = 0, + [78] = 0, + [79] = 0, + [80] = 0, + [81] = 0, + [82] = 0, + [83] = 0, + [84] = 0, + [85] = 0, + [86] = 0, + [87] = 0, + [88] = 0, + [89] = 0, + [90] = 0, + [91] = 0, + [92] = 5, + [93] = 0, + [94] = 0, + [95] = 0, + }, + SHIP = { + [0] = 0, + [1] = 0, + [2] = 6, + [3] = 6, + [4] = 4, + [5] = 0, + [6] = 0, + [7] = 1, + [8] = 1, + [9] = 5, + [10] = 5, + [11] = 0, + [12] = 5, + [13] = 0, + [14] = 1, + [15] = 1, + [16] = 0, + [17] = 0, + [18] = 6, + [19] = 6, + [20] = 3, + [21] = 0, + [22] = 0, + [23] = 1, + [24] = 1, + [25] = 5, + [26] = 5, + [27] = 0, + [28] = 5, + [29] = 1, + [30] = 1, + [31] = 1, + [32] = 6, + [33] = 6, + [34] = 0, + [35] = 4, + [36] = 1, + [37] = 0, + [38] = 0, + [39] = 5, + [40] = 5, + [41] = 6, + [42] = 6, + [43] = 5, + [44] = 5, + [45] = 5, + [46] = 2, + [47] = 2, + [48] = 6, + [49] = 6, + [50] = 0, + [51] = 0, + [52] = 1, + [53] = 1, + [54] = 5, + [55] = 5, + [56] = 5, + [57] = 6, + [58] = 6, + [59] = 5, + [60] = 5, + [61] = 5, + [62] = 2, + [63] = 2, + [64] = 5, + [65] = 5, + [66] = 1, + [67] = 1, + [68] = 5, + [69] = 5, + [70] = 0, + [71] = 0, + [72] = 1, + [73] = 1, + [74] = 4, + [75] = 0, + [76] = 0, + [77] = 0, + [78] = 0, + [79] = 0, + [80] = 5, + [81] = 5, + [82] = 0, + [83] = 0, + [84] = 1, + [85] = 1, + [86] = 0, + [87] = 0, + [88] = 1, + [89] = 1, + [90] = 0, + [91] = 0, + [92] = 0, + [93] = 0, + [94] = 0, + [95] = 0, + }, + SHIP_PORT = { + [0] = 0, + [1] = 2, + [2] = 3, + [3] = 3, + [4] = 3, + [5] = 0, + [6] = 0, + [7] = 0, + [8] = 0, + [9] = 5, + [10] = 5, + [11] = 0, + [12] = 0, + [13] = 3, + [14] = 3, + [15] = 3, + [16] = 3, + [17] = 3, + [18] = 5, + [19] = 5, + [20] = 3, + [21] = 0, + [22] = 0, + [23] = 0, + [24] = 0, + [25] = 0, + [26] = 0, + [27] = 0, + [28] = 0, + [29] = 0, + [30] = 5, + [31] = 5, + [32] = 3, + [33] = 0, + [34] = 5, + [35] = 5, + [36] = 0, + [37] = 0, + [38] = 0, + [39] = 0, + [40] = 0, + [41] = 0, + [42] = 3, + [43] = 0, + [44] = 0, + [45] = 0, + [46] = 0, + [47] = 5, + [48] = 3, + [49] = 3, + [50] = 5, + [51] = 5, + [52] = 0, + [53] = 0, + [54] = 0, + [55] = 0, + [56] = 0, + [57] = 0, + [58] = 5, + [59] = 5, + [60] = 5, + [61] = 0, + [62] = 0, + [63] = 0, + [64] = 3, + [65] = 0, + [66] = 5, + [67] = 5, + [68] = 0, + [69] = 0, + [70] = 0, + [71] = 0, + [72] = 5, + [73] = 5, + [74] = 5, + [75] = 5, + [76] = 5, + [77] = 0, + [78] = 5, + [79] = 5, + [80] = 2, + [81] = 3, + [82] = 0, + [83] = 0, + [84] = 0, + [85] = 0, + [86] = 2, + [87] = 2, + [88] = 5, + [89] = 5, + [90] = 0, + [91] = 3, + [92] = 0, + [93] = 0, + [94] = 0, + [95] = 0, + }, + UNDERGROUND = { + [0] = 0, + [1] = 1, + [2] = 0, + [3] = 5, + [4] = 5, + [5] = 5, + [6] = 5, + [7] = 5, + [8] = 5, + [9] = 5, + [10] = 5, + [11] = 1, + [12] = 1, + [13] = 0, + [14] = 0, + [15] = 0, + [16] = 0, + [17] = 0, + [18] = 0, + [19] = 5, + [20] = 5, + [21] = 1, + [22] = 0, + [23] = 0, + [24] = 1, + [25] = 0, + [26] = 0, + [27] = 0, + [28] = 0, + [29] = 0, + [30] = 0, + [31] = 0, + [32] = 0, + [33] = 0, + [34] = 0, + [35] = 0, + [36] = 0, + [37] = 0, + [38] = 0, + [39] = 0, + [40] = 0, + [41] = 0, + [42] = 0, + [43] = 0, + [44] = 0, + [45] = 0, + [46] = 0, + [47] = 0, + [48] = 0, + [49] = 0, + [50] = 0, + [51] = 0, + [52] = 0, + [53] = 0, + [54] = 0, + [55] = 0, + [56] = 0, + [57] = 0, + [58] = 0, + [59] = 0, + [60] = 0, + [61] = 0, + [62] = 0, + [63] = 0, + [64] = 0, + [65] = 0, + [66] = 0, + [67] = 0, + [68] = 0, + [69] = 0, + [70] = 0, + [71] = 0, + [72] = 0, + [73] = 0, + [74] = 0, + [75] = 0, + [76] = 0, + [77] = 0, + [78] = 0, + [79] = 0, + [80] = 0, + [81] = 0, + [82] = 0, + [83] = 0, + [84] = 0, + [85] = 0, + [86] = 0, + [87] = 0, + [88] = 0, + [89] = 0, + [90] = 0, + [91] = 0, + [92] = 0, + [93] = 0, + [94] = 0, + [95] = 0, + }, + }, + }, } diff --git a/data/scripts/victories.lua b/data/scripts/victories.lua index 98be5682..66dc1b39 100644 --- a/data/scripts/victories.lua +++ b/data/scripts/victories.lua @@ -5,24 +5,46 @@ -- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him -- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed. -- The TM each gym leader hands out afterwards is also ported. +-- +-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script +-- sets to retire unfought non-leader trainers (PewterGym.asm +-- "; deactivate gym trainers" / SetEventRange in the other gyms, and +-- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER .. +-- EVENT_BEAT_FIGHTING_DOJO_TRAINER_3). + +local function range(prefix, first, last) + local t = {} + for i = first, last do + t[#t + 1] = prefix .. i + end + return t +end return { ["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK", - item = "TM_BIDE" }, + item = "TM_BIDE", + deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" } }, ["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY", - item = "TM_BUBBLEBEAM" }, + item = "TM_BUBBLEBEAM", + deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) }, ["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE", - item = "TM_THUNDERBOLT" }, + item = "TM_THUNDERBOLT", + deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2) }, ["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA", - item = "TM_MEGA_DRAIN" }, + item = "TM_MEGA_DRAIN", + deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6) }, ["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA", - item = "TM_TOXIC" }, + item = "TM_TOXIC", + deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5) }, ["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA", - item = "TM_PSYWAVE" }, + item = "TM_PSYWAVE", + deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6) }, ["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE", - item = "TM_FIRE_BLAST" }, + item = "TM_FIRE_BLAST", + deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6) }, ["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI", - item = "TM_FISSURE" }, + item = "TM_FISSURE", + deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7) }, -- Silph Co. Giovanni: unlocks the president's Master Ball gift ["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" }, @@ -31,7 +53,8 @@ return { -- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER, -- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is -- only him (data/maps/objects/FightingDojo.asm). - ["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER" }, + ["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER", + deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3) }, -- Elite Four progress flags (their rooms' door logic isn't ported, but -- the flags make the Hall of Fame checkable) diff --git a/main.lua b/main.lua index 3c56fd3a..739003ce 100644 --- a/main.lua +++ b/main.lua @@ -223,6 +223,9 @@ function love.quit() if editorMode and EditorApp.quit then return EditorApp.quit() -- return true to abort quit end + pcall(function() + require("src.core.DiscordPresence").shutdown() + end) end function love.filedropped(file) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index b25167e6..44363626 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -425,6 +425,14 @@ function BattleState.newTrainer(game, oppClass, partyIndex) self.oppClass = oppClass self.trainer = game.data.trainers[oppClass] assert(self.trainer, "unknown trainer class " .. tostring(oppClass)) + -- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName + -- instead of TrainerNames ("RIVAL1" etc.). Overlay so we don't mutate + -- the shared data table. + if oppClass == "OPP_RIVAL1" or oppClass == "OPP_RIVAL2" + or oppClass == "OPP_RIVAL3" then + local rivalName = (game.save.player and game.save.player.rival) or "BLUE" + self.trainer = setmetatable({ name = rivalName }, { __index = self.trainer }) + end self.enemyAIMods = self.trainer.aiMods local partyDef = self.trainer.parties[partyIndex or 1] assert(partyDef, ("trainer %s has no party %s"):format(oppClass, tostring(partyIndex))) diff --git a/src/core/Data.lua b/src/core/Data.lua index 3a0f4621..47cf2429 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -97,6 +97,30 @@ function Data:seedDefaults() -- the overworld's Kanto literals, same fill-if-absent contract; required -- here rather than at the top so core keeps out of src/world at load time require("src.world.FieldDefaults").seed(self) + -- Cinnabar's quiz trainers are text_asm (no def_trainers), so the + -- extractor never writes headers for them. Seed the EVENT_BEAT_* / + -- after-battle rows so Blaine's SetEventRange deactivation and talk + -- after-text work like the other gyms (scripts/CinnabarGym.asm). + self:seedCinnabarGymTrainerHeaders() +end + +function Data:seedCinnabarGymTrainerHeaders() + local headers = self.trainer_headers + if not headers or headers.CinnabarGym then return end + local gym = {} + for i = 0, 6 do + local n = i + 1 + -- object indices 2..8 are SUPER_NERD1..7; range 0 -- they only + -- engage via talk / wrong quiz answer, never sight lines + gym[i + 2] = { + event = "EVENT_BEAT_CINNABAR_GYM_TRAINER_" .. i, + range = 0, + battle = "_CinnabarGymSuperNerd" .. n .. "BattleText", + won = "_CinnabarGymSuperNerd" .. n .. "EndBattleText", + after = "_CinnabarGymSuperNerd" .. n .. "AfterBattleText", + } + end + headers.CinnabarGym = gym end -- POKEPORT_DATA_DIR points a test runner at another dataset root (the diff --git a/src/core/DiscordPresence.lua b/src/core/DiscordPresence.lua new file mode 100644 index 00000000..2be37aad --- /dev/null +++ b/src/core/DiscordPresence.lua @@ -0,0 +1,658 @@ +-- Discord Rich Presence via the local Discord IPC pipe. Shows the player's +-- current map (and battle status) on their Discord profile. Desktop only; +-- no-ops on mobile, headless runs, or when Discord is not running. +-- +-- Application ID is the only credential needed for presence. The Discord +-- Interactions public key is unused here (that is for verifying bot HTTP +-- payloads, not Rich Presence). + +local Json = require("src.link.Json") +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") + +local DiscordPresence = { + APP_ID = "1529183141267374262", +} + +local OP_HANDSHAKE, OP_FRAME, OP_CLOSE = 0, 1, 2 + +local MIN_UPDATE_INTERVAL = 2.0 +local RECONNECT_INTERVAL = 30.0 + +local state = { + enabled = false, + connected = false, + socket = nil, -- Windows: file handle; Unix: ffi sockfd + isWindows = false, + ffi = nil, + ffiReady = false, + startedAt = 0, + dirty = false, + lastSentKey = nil, + lastSendAt = -1e9, + nextReconnectAt = 0, + location = nil, -- display name + mapId = nil, + activity = "menu", -- menu | exploring | battle + battleLabel = nil, + unsubs = {}, + pid = nil, + loggedAbsent = false, -- one quiet note when Discord isn't running +} + +local function disabledByEnv() + return os.getenv("POKEPORT_NO_DISCORD") == "1" + or os.getenv("POKEPORT_AUTOPILOT") ~= nil + or os.getenv("POKEPORT_DRIVER") ~= nil +end + +local function isDesktop() + if not love or not love.system or not love.system.getOS then return false end + local osName = love.system.getOS() + return osName == "OS X" or osName == "Windows" or osName == "Linux" +end + +local function packU32(n) + n = math.floor(n) % 0x100000000 + local b1 = n % 256; n = math.floor(n / 256) + local b2 = n % 256; n = math.floor(n / 256) + local b3 = n % 256; n = math.floor(n / 256) + local b4 = n % 256 + return string.char(b1, b2, b3, b4) +end + +local function unpackU32(s) + local b1, b2, b3, b4 = s:byte(1, 4) + return b1 + b2 * 256 + b3 * 65536 + b4 * 16777216 +end + +local function nonce() + return string.format("%08x-%04x-4%03x-%04x-%04x%08x", + math.random(0, 0xffffffff), + math.random(0, 0xffff), + math.random(0, 0xfff), + math.random(0, 0x3fff) + 0x8000, + math.random(0, 0xffff), + math.random(0, 0xffffffff)) +end + +local function resolvePid() + if state.pid then return state.pid end + local ffi = state.ffi + if ffi then + if state.isWindows then + pcall(ffi.cdef, "unsigned long GetCurrentProcessId(void);") + local ok, p = pcall(function() return ffi.C.GetCurrentProcessId() end) + if ok and p and tonumber(p) and tonumber(p) > 0 then + state.pid = tonumber(p) + return state.pid + end + else + pcall(ffi.cdef, "int getpid(void);") + local ok, p = pcall(function() return ffi.C.getpid() end) + if ok and p and tonumber(p) and tonumber(p) > 0 then + state.pid = tonumber(p) + return state.pid + end + end + end + state.pid = 1000 + math.random(1, 8999) + return state.pid +end + +local function ensureFfi() + if state.ffiReady then return state.ffi end + if state.ffi == false then return nil end + local ok, ffi = pcall(require, "ffi") + if not ok or not ffi then + state.ffi = false + return nil + end + state.ffi = ffi + if state.isWindows then + state.ffiReady = true + return ffi + end + -- Darwin sockaddr_un has sun_len + uint8_t family; Linux uses u16 family. + local osName = love.system.getOS() + local cdef + if osName == "OS X" then + cdef = [[ + typedef unsigned int socklen_t; + typedef int ssize_t; + struct sockaddr_un { + uint8_t sun_len; + uint8_t sun_family; + char sun_path[104]; + }; + struct timeval { long tv_sec; int tv_usec; }; + int socket(int domain, int type, int protocol); + int connect(int sockfd, const void *addr, socklen_t addrlen); + int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen); + ssize_t send(int sockfd, const void *buf, size_t len, int flags); + ssize_t recv(int sockfd, void *buf, size_t len, int flags); + int close(int fd); + ]] + else + cdef = [[ + typedef unsigned int socklen_t; + typedef int ssize_t; + struct sockaddr_un { + unsigned short sun_family; + char sun_path[108]; + }; + struct timeval { long tv_sec; long tv_usec; }; + int socket(int domain, int type, int protocol); + int connect(int sockfd, const void *addr, socklen_t addrlen); + int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen); + ssize_t send(int sockfd, const void *buf, size_t len, int flags); + ssize_t recv(int sockfd, void *buf, size_t len, int flags); + int close(int fd); + ]] + end + -- cdef may already be registered from a prior boot; either way we need + -- sockaddr_un usable before claiming readiness + pcall(ffi.cdef, cdef) + local ready = pcall(function() return ffi.new("struct sockaddr_un") end) + if not ready then + state.ffi = false + state.ffiReady = false + return nil + end + state.ffiReady = true + return ffi +end + +local function closeSocket() + local sock = state.socket + state.socket = nil + state.connected = false + if not sock then return end + if state.isWindows then + pcall(function() sock:close() end) + elseif state.ffi then + pcall(state.ffi.C.close, sock) + end +end + +local function writeRaw(bytes) + if not state.socket then return false end + if state.isWindows then + local ok = pcall(function() + state.socket:seek("end") + local _, writeErr = state.socket:write(bytes) + state.socket:flush() + if writeErr then error(writeErr) end + end) + return ok + end + local ffi = state.ffi + if not ffi then return false end + local ok, sent = pcall(ffi.C.send, state.socket, bytes, #bytes, 0) + return ok and sent == #bytes +end + +local function sendFrame(opcode, payload) + local body = payload or "" + local ok, frame = pcall(function() + return packU32(opcode) .. packU32(#body) .. body + end) + if not ok or not frame then return false end + return writeRaw(frame) +end + +local function readExact(n) + if not state.socket or n <= 0 then return nil end + if state.isWindows then + local ok, data = pcall(function() return state.socket:read(n) end) + if not ok or not data or #data < n then return nil end + return data + end + local ffi = state.ffi + if not ffi then return nil end + local ok, data = pcall(function() + local buf = ffi.new("char[?]", n) + local got = 0 + while got < n do + local nread = ffi.C.recv(state.socket, buf + got, n - got, 0) + if not nread or nread <= 0 then return nil end + got = got + tonumber(nread) + end + return ffi.string(buf, n) + end) + if not ok then return nil end + return data +end + +local function receiveFrame() + local header = readExact(8) + if not header then return nil end + local ok, opcode, length = pcall(function() + return unpackU32(header:sub(1, 4)), unpackU32(header:sub(5, 8)) + end) + if not ok or not opcode or not length or length < 0 or length > 65536 then + return nil + end + local data = length > 0 and readExact(length) or "" + if data == nil then return nil end + return opcode, data +end + +local function unixSocketPaths() + local paths = {} + local envs = { "XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP" } + local bases = {} + for _, key in ipairs(envs) do + local v = os.getenv(key) + if v and v ~= "" then + if v:sub(-1) == "/" then v = v:sub(1, -2) end + bases[#bases + 1] = v + end + end + bases[#bases + 1] = "/tmp" + local suffixes = { + "", + "/app/com.discordapp.Discord", + "/app/com.discordapp.DiscordCanary", + "/app/com.discordapp.DiscordPTB", + "/snap.discord", + "/snap.discord-canary", + } + local seen = {} + for i = 0, 9 do + local name = "discord-ipc-" .. i + for _, base in ipairs(bases) do + for _, suffix in ipairs(suffixes) do + local path = base .. suffix .. "/" .. name + if not seen[path] then + seen[path] = true + paths[#paths + 1] = path + end + end + end + end + return paths +end + +local function setSocketTimeout(fd, seconds) + local ffi = state.ffi + if not ffi or not fd then return end + pcall(function() + local tv = ffi.new("struct timeval") + tv.tv_sec = seconds + tv.tv_usec = 0 + -- SOL_SOCKET / SO_RCVTIMEO / SO_SNDTIMEO differ by OS + local sol, rcv, snd + if love.system.getOS() == "OS X" then + sol, rcv, snd = 0xffff, 0x1006, 0x1005 + else + sol, rcv, snd = 1, 20, 21 + end + ffi.C.setsockopt(fd, sol, rcv, tv, ffi.sizeof(tv)) + ffi.C.setsockopt(fd, sol, snd, tv, ffi.sizeof(tv)) + end) +end + +local function connectUnix() + local ffi = ensureFfi() + if not ffi then return false end + local darwin = love.system.getOS() == "OS X" + local maxPath = darwin and 103 or 107 + -- AF_UNIX=1, SOCK_STREAM=1 on Linux/macOS + local pathsOk, paths = pcall(unixSocketPaths) + if not pathsOk or not paths then return false end + for _, path in ipairs(paths) do + if #path <= maxPath then + local ok, connected = pcall(function() + local fd = ffi.C.socket(1, 1, 0) + if not fd or fd < 0 then return false end + local addr = ffi.new("struct sockaddr_un") + addr.sun_family = 1 + if darwin then + -- SUN_LEN: sizeof(sun_len+sun_family) + path + NUL + addr.sun_len = 2 + #path + 1 + end + ffi.copy(addr.sun_path, path) + local rc = ffi.C.connect(fd, ffi.cast("void*", addr), ffi.sizeof(addr)) + if rc == 0 then + setSocketTimeout(fd, 1) -- handshake must not freeze the game + state.socket = fd + return true + end + ffi.C.close(fd) + return false + end) + if ok and connected then return true end + end + end + return false +end + +local function connectWindows() + for i = 0, 9 do + local path = "\\\\.\\pipe\\discord-ipc-" .. i + local ok, file = pcall(io.open, path, "r+") + if ok and file then + state.socket = file + return true + end + end + return false +end + +local function handshake() + local ok, payload = pcall(Json.encode, { v = 1, client_id = DiscordPresence.APP_ID }) + if not ok or not payload then return false end + if not sendFrame(OP_HANDSHAKE, payload) then return false end + -- Discord echoes READY as a FRAME. A timeout/missing client is a soft miss. + local opcode = receiveFrame() + if opcode == OP_FRAME then return true end + -- Windows named pipes have no easy read timeout: if the write succeeded + -- and Discord is present, proceed optimistically so boot never blocks. + if state.isWindows then return true end + return false +end + +local function noteAbsent() + if state.loggedAbsent then return end + state.loggedAbsent = true + -- info, not warn: missing Discord is normal for many players + Logger.info("discord: rich presence unavailable (Discord not running)") +end + +local function connect() + local ok, result = pcall(function() + closeSocket() + local linked + if state.isWindows then + linked = connectWindows() + else + linked = connectUnix() + end + if not linked then return false end + if not handshake() then + closeSocket() + return false + end + state.connected = true + state.dirty = true + state.lastSentKey = nil + state.loggedAbsent = false + Logger.info("discord: rich presence connected") + return true + end) + if not ok then + closeSocket() + return false + end + return result and true or false +end + +local function locationName(game, mapId) + if not mapId then return nil end + local field = game and game.data and game.data.field + local townMap = field and field.townMap + local locations = townMap and (townMap.locations or townMap) + local entry = type(locations) == "table" and locations[mapId] + if type(entry) == "table" then + local name = entry.name or entry.label + if type(name) == "string" and name ~= "" then return name end + end + local def = game and game.data and game.data.maps and game.data.maps[mapId] + if def and type(def.label) == "string" and def.label ~= "" then + -- PalletTown -> Pallet Town + return def.label:gsub("(%l)(%u)", "%1 %2") + end + return tostring(mapId):gsub("_", " ") +end + +local function speciesName(game, species) + if not species or not game or not game.data or not game.data.pokemon then + return nil + end + local def = game.data.pokemon[species] + return def and def.name or tostring(species):gsub("_", " ") +end + +local function buildActivity() + local details, activityState + if state.activity == "battle" then + details = state.battleLabel or "In battle" + activityState = state.location and (state.location) or "Kanto" + elseif state.activity == "exploring" and state.location then + details = state.location + activityState = "Exploring the overworld" + else + details = state.location or "Kanto" + activityState = "At the title screen" + end + + return { + details = details, + state = activityState, + timestamps = { start = state.startedAt }, + assets = { + large_image = "logo", + large_text = "Pokemon Gen1Recomp", + }, + } +end + +local function activityKey(activity) + return (activity.details or "") .. "|" .. (activity.state or "") +end + +local function flush(force) + if not state.enabled or not state.connected then return end + local ok = pcall(function() + local now = love.timer.getTime() + if not force and (now - state.lastSendAt) < MIN_UPDATE_INTERVAL then + state.dirty = true + return + end + local activity = buildActivity() + local key = activityKey(activity) + if not force and key == state.lastSentKey then + state.dirty = false + return + end + local payload = Json.encode({ + cmd = "SET_ACTIVITY", + args = { + pid = resolvePid(), + activity = activity, + }, + nonce = nonce(), + }) + if not sendFrame(OP_FRAME, payload) then + closeSocket() + state.nextReconnectAt = now + RECONNECT_INTERVAL + return + end + state.lastSentKey = key + state.lastSendAt = now + state.dirty = false + end) + if not ok then + closeSocket() + local now = love and love.timer and love.timer.getTime and love.timer.getTime() or 0 + state.nextReconnectAt = now + RECONNECT_INTERVAL + end +end + +local function setPresence(fields) + -- event listeners must never throw into the overworld/battle path + pcall(function() + if fields.location ~= nil then state.location = fields.location end + if fields.mapId ~= nil then state.mapId = fields.mapId end + if fields.activity ~= nil then state.activity = fields.activity end + if fields.clearBattle then + state.battleLabel = nil + elseif fields.battleLabel ~= nil then + state.battleLabel = fields.battleLabel + end + state.dirty = true + flush(false) + end) +end + +local function subscribe(game) + local events = Runtime.events + if not events or not events.on then return end + + local function track(name, fn) + state.unsubs[#state.unsubs + 1] = events:on(name, fn, 0, "discord") + end + + track("map.entered", function(ev) + local mapId = ev and ev.mapId + setPresence({ + mapId = mapId, + location = locationName(game, mapId), + activity = "exploring", + clearBattle = true, + }) + end) + + track("battle.started", function(ev) + local battle = ev and ev.battle + local label = "In battle" + if ev and ev.kind == "wild" then + local name = speciesName(game, ev.species) + if name then + label = "Battling wild " .. name + if ev.level then label = label .. " Lv" .. tostring(ev.level) end + else + label = "Wild battle" + end + elseif ev and ev.kind == "trainer" then + local tname = battle and battle.trainer and battle.trainer.name + label = tname and ("Battling " .. tname) or "Trainer battle" + elseif ev and ev.kind == "link" then + label = "Link battle" + end + local mapId = state.mapId + or (game.save and game.save.player and game.save.player.map) + setPresence({ + activity = "battle", + battleLabel = label, + location = locationName(game, mapId) or state.location, + mapId = mapId, + }) + end) + + track("battle.ended", function() + local mapId = state.mapId + or (game.save and game.save.player and game.save.player.map) + setPresence({ + activity = mapId and "exploring" or "menu", + clearBattle = true, + location = locationName(game, mapId) or state.location, + mapId = mapId, + }) + end) + + track("screen.pushed", function(ev) + local s = ev and ev.state + local id = s and s.screenId + if not id and s and s.onNewGame then id = "TitleState" end + if id == "TitleState" or id == "IntroMovie" then + state.mapId = nil + setPresence({ + activity = "menu", + location = id == "IntroMovie" and "Watching the intro" or "Title screen", + clearBattle = true, + }) + end + end) +end + +function DiscordPresence.init(game) + local ok, err = pcall(function() + DiscordPresence.shutdown() + if disabledByEnv() or not isDesktop() then return end + + state.enabled = true + state.isWindows = love.system.getOS() == "Windows" + state.startedAt = os.time() + state.activity = "menu" + state.location = "Title screen" + state.mapId = nil + state.battleLabel = nil + state.dirty = true + state.nextReconnectAt = 0 + state.loggedAbsent = false + + if not state.isWindows and not ensureFfi() then + -- no FFI / broken cdef: stay disabled, never throw + state.enabled = false + return + end + + subscribe(game) + if connect() then + flush(true) + else + noteAbsent() + local now = (love.timer and love.timer.getTime and love.timer.getTime()) or 0 + state.nextReconnectAt = now + RECONNECT_INTERVAL + end + end) + if not ok then + closeSocket() + state.enabled = false + -- still soft: a broken presence stack must not abort boot + Logger.info("discord: rich presence disabled (%s)", tostring(err)) + end +end + +function DiscordPresence.update(_dt) + if not state.enabled then return end + pcall(function() + local now = love.timer.getTime() + if not state.connected then + if now >= state.nextReconnectAt then + state.nextReconnectAt = now + RECONNECT_INTERVAL + if connect() then + flush(true) + else + noteAbsent() + end + end + return + end + if state.dirty then flush(false) end + end) +end + +function DiscordPresence.shutdown() + pcall(function() + for _, unsub in ipairs(state.unsubs) do + pcall(unsub) + end + state.unsubs = {} + if state.connected then + pcall(function() + -- omit activity to clear the presence + sendFrame(OP_FRAME, Json.encode({ + cmd = "SET_ACTIVITY", + args = { pid = resolvePid() }, + nonce = nonce(), + })) + sendFrame(OP_CLOSE, "{}") + end) + end + closeSocket() + end) + state.enabled = false + state.dirty = false + state.lastSentKey = nil + state.connected = false + state.socket = nil +end + +-- test / debug helpers +DiscordPresence._state = state +DiscordPresence.locationName = locationName + +return DiscordPresence diff --git a/src/core/Game.lua b/src/core/Game.lua index e4b3e022..83508a4f 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -68,6 +68,10 @@ function Game:load() local OverworldState = require("src.world.OverworldController") self.overworld = OverworldState + -- Discord Rich Presence: map name / battle status on the player's profile. + -- Soft-fail: missing Discord / IPC errors must never block boot. + pcall(function() require("src.core.DiscordPresence").init(self) end) + -- every service is up but nothing is on the stack yet; this payload is -- the sanctioned way for a mod to obtain the Game object ModRuntime.emit("game.ready", { game = self }) @@ -103,7 +107,7 @@ end function Game:makeTitleState() local OverworldState = require("src.world.OverworldController") local factory = Screens.get(self, bootScreens(self).title or "TitleState") - return factory.new(self, { + local title = factory.new(self, { onNewGame = function() while self.stack:top() do self.stack:pop() end -- New Game keeps the standalone options.lua preferences @@ -126,6 +130,8 @@ function Game:makeTitleState() end end, }) + title.screenId = title.screenId or "TitleState" + return title end -- QUIT from the START menu: back to the title like a power-cycle, @@ -187,6 +193,7 @@ function Game:update(dt) -- 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) + pcall(function() require("src.core.DiscordPresence").update(dt) end) end -- render.zones' identity default: unhooked, the zone list reaches the blit @@ -391,6 +398,7 @@ function Game:applyOptions(opts) require("src.render.PaletteFX").applyOptions(opts) require("src.render.Tilt").applyOptions(opts) require("src.render.GBCFX").applyOptions(opts) + require("src.core.VideoMode").applyOptions(opts) end function Game:restoreSave(loaded, recovered) diff --git a/src/core/GameSpeed.lua b/src/core/GameSpeed.lua index a21309d0..85b81b3c 100644 --- a/src/core/GameSpeed.lua +++ b/src/core/GameSpeed.lua @@ -18,7 +18,7 @@ local GameSpeed = {} -- 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, 100,500 } +GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75, 100,200 } GameSpeed.DEFAULT = 1 function GameSpeed.levelLabel(v) diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 9bf30d59..56b540be 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -47,6 +47,8 @@ function SaveData.defaultOptions() colors = "gbc", tilt = 0, gbcfx = 0, + -- windowed | borderless (desktop fullscreen); ignored on mobile + videoMode = "windowed", -- Native mod enablement is an installation option, not save-slot data. -- Missing entries mean enabled so newly installed mods work by default. mods = {}, diff --git a/src/core/VideoMode.lua b/src/core/VideoMode.lua new file mode 100644 index 00000000..39dc715a --- /dev/null +++ b/src/core/VideoMode.lua @@ -0,0 +1,54 @@ +-- Windowed vs borderless (desktop) fullscreen. +-- +-- Persisted as save.options.videoMode. Applied from OptionsMenu and on +-- boot via Game:applyOptions. No-ops on mobile and in headless stubs +-- that lack love.window. + +local VideoMode = {} + +VideoMode.MODES = { "windowed", "borderless" } +VideoMode.DEFAULT = "windowed" + +local LABELS = { + windowed = "WINDOWED", + borderless = "BORDERLESS", +} + +function VideoMode.normalize(mode) + if mode == "borderless" then return "borderless" end + return VideoMode.DEFAULT +end + +function VideoMode.modeLabel(mode) + return LABELS[VideoMode.normalize(mode)] or LABELS[VideoMode.DEFAULT] +end + +function VideoMode.isMobile() + if not love or not love.system or not love.system.getOS then return false end + local osName = love.system.getOS() + return osName == "Android" or osName == "iOS" +end + +-- Cycle windowed <-> borderless (dir ignored; two modes). +function VideoMode.cycle(mode, _dir) + return VideoMode.normalize(mode) == "borderless" and "windowed" or "borderless" +end + +-- Push the mode into the live window. Safe when love.window is missing. +function VideoMode.apply(mode) + if VideoMode.isMobile() then return end + if not love or not love.window or not love.window.setFullscreen then return end + mode = VideoMode.normalize(mode) + if mode == "borderless" then + -- "desktop" = borderless fullscreen matching the display + love.window.setFullscreen(true, "desktop") + else + love.window.setFullscreen(false) + end +end + +function VideoMode.applyOptions(opts) + VideoMode.apply(opts and opts.videoMode) +end + +return VideoMode diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 445eec20..0d65b52c 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -196,6 +196,114 @@ function PaletteFX.monPalName(data, species, transformed) return "MEWMON" end +-- ------- true GBC overworld coloring (color/loadpalettes.asm, +-- color/data/*, color/sprites.asm ColorOverworldSprite) ------------------- +-- +-- RED++ pairs its named-palette battle/mon colors above with pokered-gbc's +-- real per-tile system: LoadTilesetPalette assigns one of 8 four-color BG +-- palettes to every tile GRAPHIC in a tileset (by tile id, not by map +-- position), and LoadTownPalette swaps just the ROOF slot (index 6) per +-- town/route. `data/palettes_gbc.lua`'s `world` table holds the extracted +-- data (tools/extract/palettes.py extract_gbc_world); these queries are +-- mode-independent (only check the pack exists) so TileRenderer can +-- precompute geometry once regardless of the active COLORS mode -- callers +-- that resolve to actual on-screen COLOR should gate on usesGbcPack() +-- themselves, the same way they already gate other RED++-only behavior. +-- +-- LoadTilesetPalette's 3 hardcoded single-tile fixes (Celadon Mart) and +-- LoadTownPalette's Route 6/Saffron y<2 roof split are control flow, not +-- data, so they are not in the extracted pack -- they live here instead. +local TILE_GROUP_EXCEPTIONS = { + -- tile ids $4b-$4f -> BLUE (outside sky, seen through the mart's roof) + CELADON_MART_ROOF = { tiles = { [0x4b] = true, [0x4c] = true, [0x4d] = true, + [0x4e] = true, [0x4f] = true }, group = 3 }, + -- tile $37 -> BROWN (counter miscoloration fix) + CELADON_MART_3F = { tiles = { [0x37] = true }, group = 5 }, + -- tiles $07/$08/$17/$18 -> YELLOW (bench, blue by default) + CELADON_MART_1F = { tiles = { [0x07] = true, [0x08] = true, + [0x17] = true, [0x18] = true }, group = 4 }, +} +local ROOF_GROUP = 6 +local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 } + +-- whether the extracted pack has real per-tile GBC data for this tileset +-- (false for a mod tileset with no pokered-gbc counterpart, or when the +-- pack failed to load at all) +function PaletteFX.hasWorldTileset(tileset) + local pack = PaletteFX.gbcPack() + local w = pack and pack.world + return (w and w.tileGroups[tileset]) ~= nil +end + +-- the palette-group (0-7) a tile GRAPHIC id resolves to in this tileset, +-- with the current map's tile-id exceptions (if any) applied first +function PaletteFX.worldGroupAt(tileset, mapId, tileId) + local pack = PaletteFX.gbcPack() + local w = pack and pack.world + local groups = w and w.tileGroups[tileset] + if not groups then return nil end + local exc = TILE_GROUP_EXCEPTIONS[mapId] + if exc and exc.tiles[tileId] then return exc.group end + return groups[tileId] or 7 -- TEXT: tile ids past the tileset's 96 (menus) +end + +-- this tileset's resolved 8-entry {r,g,b}x4 palette array, with the ROOF +-- slot swapped to the current town/route (Route 6's north end uses +-- Saffron's roof colors while the player stands in its top 2 cell rows, +-- like pokered's wYCoord check -- data is Game.data, for the map lookup) +function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY) + local pack = PaletteFX.gbcPack() + local w = pack and pack.world + local base = w and w.groupColors[tileset] + if not base then return nil end + if not w.roofGroup[tileset] then return base end + local roofMapId = mapId + if mapId == ROUTE_6_SAFFRON.mapId and playerCellY + and playerCellY < ROUTE_6_SAFFRON.cellYBelow then + roofMapId = ROUTE_6_SAFFRON.useMapId + end + local roofMap = data and data.maps and data.maps[roofMapId] + local roof = roofMap and w.roofByMapIndex[roofMap.index] + if not roof then return base end + local out = {} + for i = 1, 8 do out[i] = base[i] end + -- LoadTownPalette only overwrites W2_BgPaletteData + $32, i.e. colors 1 + -- and 2 (0-indexed) of the 4-color ROOF slot -- color 0 (background, + -- typically the sky-through-gaps white) and color 3 (outline black) keep + -- the tileset's own OUTDOOR_ROOF/INDOOR_ROOF base, only the roof + -- material's 2 middle shades are town-specific + local base4 = base[ROOF_GROUP + 1] + out[ROOF_GROUP + 1] = { base4[1], roof[1], roof[2], base4[4] } + return out +end + +-- an overworld sprite's resolved 4-color OBJ palette (ColorOverworldSprite), +-- or nil when unassigned/unavailable, plus the resolved group index (for +-- callers that want a stable cache key without hashing the colors table). +-- spriteDef carries the ROM picture-id crosswalk in its `source` field +-- ("ROM:SpriteSheetPointerTable[N]"); seed (any stable per-instance value, +-- e.g. an NPC's `id`) resolves the "random" sentinel -- a deliberate +-- approximation of ColorOverworldSprite's per-OAM-slot pseudo-random pick +-- (`swap a; and 3` on the sprite's OAM offset, which has no equivalent +-- here): a stable hash instead, so the same NPC instance always shows the +-- same one of the 4 SPR_PAL_* colors. +function PaletteFX.spriteObp(spriteDef, seed) + local pack = PaletteFX.gbcPack() + local w = pack and pack.world + local src = spriteDef and spriteDef.source + if not (w and src) then return nil end + local idx = tonumber(src:match("%[(%d+)%]")) + local group = idx and w.spriteAssignment[idx] + if group == nil then return nil end + if group == "random" then + local h = 0 + seed = tostring(seed or "") + for i = 1, #seed do h = (h * 31 + seed:byte(i)) % 4294967296 end + group = h % 4 + end + return w.spritePalettes[group], group +end + -- GetHealthBarColor (home/palettes.asm) on the standard 48px bar function PaletteFX.barPalName(hp, maxHp) local px = maxHp > 0 and math.floor(hp * 48 / maxHp) or 0 @@ -237,10 +345,24 @@ function PaletteFX.setMode(mode) end end if not ok then PaletteFX.mode = "gbc" end - -- battle pics bake the active pack into ImageData; drop the cache when - -- the pack (or any COLORS mode) changes so the next draw re-tints + -- battle pics and overworld sprites bake the active pack into ImageData; + -- drop those caches when the pack (or any COLORS mode) changes so the + -- next draw re-tints if prev ~= PaletteFX.mode then pcall(function() require("src.battle.BattleState").invalidate() end) + pcall(function() require("src.render.SpriteRenderer").invalidate() end) + -- RED++'s baked tileset atlas (TileRenderer.getGbcAtlas) is built once + -- per loaded map, so a mode toggle needs every cached Map/TileRenderer + -- dropped and the currently-visible one rebuilt in place -- otherwise + -- the on-screen map keeps its stale (wrong-mode) atlas until the next + -- map transition happens to reload it. + pcall(function() + require("src.world.MapLoader").invalidateAll() + local Game = require("src.core.Game") + if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then + Game.overworld:reloadMap(Game.overworld.map.id, "colors") + end + end) end end diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index 7895eea6..f4c73923 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -18,10 +18,49 @@ local function getImage(path) return imageCache[path] end +-- RED++ overworld sprite OBJ-palette recolor (color/sprites.asm +-- ColorOverworldSprite), baked into an ImageData like BattleState's mon-pic +-- palette bake (src/battle/BattleState.lua getImage): CPU-remap the 4 DMG +-- shades to the resolved OBP colors, cached per (image path, group). +-- +-- Sprite sheets carry no real alpha (every pixel, including the +-- background, is opaque -- confirmed by sampling the extracted PNGs): the +-- "transparent" look in every other draw path is a coincidence of the +-- whole-canvas shade-remap shader, where shade 0 (white) happens to map to +-- a similarly light color in whatever terrain zone the sprite stands over. +-- That coincidence breaks once terrain is colored per-tile instead of one +-- flat color per map (different tiles can have very different color-0s), +-- so shade 0 is keyed to alpha 0 here explicitly -- matching real GBC OBJ +-- hardware, where sprite palette index 0 is unconditionally transparent +-- (same rule TileRenderer's getColor0KeyShader documents for tall grass). +local obpCache = {} + +local function getObpImage(path, colors, group) + local key = path .. "#obp" .. group + if not obpCache[key] then + local img + if love.image and love.image.newImageData then + local id = Assets.imageData(path) + id:mapPixel(function(_, _, r, g, b, a) + if a == 0 then return r, g, b, a end + if r > 0.83 then return r, g, b, 0 end -- OBJ color 0: always transparent + local col = r > 0.5 and colors[2] or r > 0.17 and colors[3] or colors[4] + return col[1] / 255, col[2] / 255, col[3] / 255, a + end) + img = love.graphics.newImage(id) + else + img = getImage(path) -- headless stub: no pixel access + end + obpCache[key] = img + end + return obpCache[key] +end + -- hot reload drops the sheets; live instances hold their own image, so -- the world rebuilds them (MapLoader.invalidateAll) rather than this function SpriteRenderer.invalidate() imageCache = {} + obpCache = {} end Assets.register(SpriteRenderer.invalidate) @@ -29,9 +68,12 @@ Assets.register(SpriteRenderer.invalidate) local STAND = { down = 0, up = 1, left = 2, right = 2 } local WALK = { down = 3, up = 4, left = 5, right = 5 } -function SpriteRenderer.new(spriteDef) +-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve +-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp) +function SpriteRenderer.new(spriteDef, seed) local self = setmetatable({}, SpriteRenderer) self.def = spriteDef + self.seed = seed self.image = getImage(spriteDef.image) local iw, ih = self.image:getDimensions() self.frames = {} @@ -46,13 +88,26 @@ end function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip) local x = math.floor(px - camX) local y = math.floor(py - camY) - 4 + local image = self.image -- full-color art claims its 16x16 cell out of the shade-remap pass - if self.def.trueColor then PaletteFX.markTrueColor(x, y, 16, 16) end + if self.def.trueColor then + PaletteFX.markTrueColor(x, y, 16, 16) + elseif PaletteFX.usesGbcPack() then + -- RED++: the world canvas is already true-color (TileRenderer bakes + -- terrain, this bakes the sprite) and the world pass runs unshaded + -- (OverworldState.sgbWorldZones), so this draws like any normal sprite + -- -- opaque character pixels over a real-alpha-transparent background, + -- no trueColor rect needed (there is no shader left to exempt it from). + local colors, group = PaletteFX.spriteObp(self.def, self.seed) + if colors then + image = getObpImage(self.def.image, colors, group) + end + end -- single-frame sprites (item balls, fossils...) have one fixed pose; -- still 3-frame sprites turn to face (the nurse at her machine, -- facePlayer on STAY NPCs) but never show walk frames if self.def.frames <= 1 then - love.graphics.draw(self.image, self.frames[0], x, y) + love.graphics.draw(image, self.frames[0], x, y) return end local frame = (self.def.walker and walkPhase == 1) @@ -65,9 +120,9 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip) end local quad = self.frames[frame] or self.frames[0] if flip then - love.graphics.draw(self.image, quad, x + 16, y, 0, -1, 1) + love.graphics.draw(image, quad, x + 16, y, 0, -1, 1) else - love.graphics.draw(self.image, quad, x, y) + love.graphics.draw(image, quad, x, y) end end diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index f8773806..b5197cbf 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -108,10 +108,23 @@ end -- the water/flower branches did before they were data. -- ------------------------------------------------------------------ --- the 8 shifted variants of one tile (built once per sheet + tile id) +-- shade 0-3 -> one of `colors`' 4 entries (same cutoffs PaletteFX's shader +-- uses), alpha passed through unchanged; nil colors leaves r,g,b as-is. +-- Shared by the whole-atlas bake (getGbcAtlas) and the animated-tile +-- variants below, so water/flowers/spinners match the static tiles around +-- them under RED++ instead of showing their un-recolored grayscale. +local function recolorSample(r, g, b, a, colors) + if not (colors and a > 0) then return r, g, b, a end + local col = r > 0.83 and colors[1] or r > 0.5 and colors[2] + or r > 0.17 and colors[3] or colors[4] + return col[1] / 255, col[2] / 255, col[3] / 255, a +end + +-- the 8 shifted variants of one tile (built once per sheet + tile id [+ +-- gbcKey, when `colors` recolors it for RED++ -- see buildAnim]) local shiftVariants = {} -local function getShiftVariants(tilesetImagePath, perRow, tile) - local key = tilesetImagePath .. "#" .. tile +local function getShiftVariants(tilesetImagePath, perRow, tile, colors, gbcKey) + local key = tilesetImagePath .. "#" .. tile .. (gbcKey or "") if shiftVariants[key] ~= nil then return shiftVariants[key] end if not (love.image and love.image.newImageData) then shiftVariants[key] = false @@ -126,6 +139,7 @@ local function getShiftVariants(tilesetImagePath, perRow, tile) for y = 0, 7 do for x = 0, 7 do local r, g, b, a = id:getPixel(sx + x, sy + y) + r, g, b, a = recolorSample(r, g, b, a, colors) v:setPixel((x + o) % 8, y, r, g, b, a) end end @@ -136,12 +150,27 @@ local function getShiftVariants(tilesetImagePath, perRow, tile) end local frameImages = {} -local function getFrameImages(paths) - local key = table.concat(paths, "|") +local function getFrameImages(paths, colors, gbcKey) + local key = table.concat(paths, "|") .. (gbcKey or "") if frameImages[key] ~= nil then return frameImages[key] end local out = {} for i, path in ipairs(paths) do - local ok, img = pcall(getImage, path) + local ok, img = pcall(function() + if not (colors and love.image and love.image.newImageData) then + return getImage(path) + end + local id = Assets.imageData(path) + local w, h = id:getDimensions() + local out2 = love.image.newImageData(w, h) + for y = 0, h - 1 do + for x = 0, w - 1 do + local r, g, b, a = id:getPixel(x, y) + r, g, b, a = recolorSample(r, g, b, a, colors) + out2:setPixel(x, y, r, g, b, a) + end + end + return love.graphics.newImage(out2) + end) if not ok then frameImages[key] = false return false @@ -237,17 +266,33 @@ end -- one entry's runtime form: the tile ids it claims, the textures a step -- picks from, and either a step sequence (hshift/frames) or a gate -- (toggle). nil when the entry's pixels could not be built. -local function buildAnim(spec, tilesetImagePath, perRow, quads) +-- +-- gbc, when present (RED++ with a baked atlas -- see getGbcAtlas), recolors +-- hshift/frames entries (water/flowers) the same way the atlas bakes their +-- static tile, so they match their surroundings instead of showing raw +-- grayscale over an otherwise fully-colored map. The "toggle" kind +-- (spinner puzzle blur, gfx/overworld/spinners.png) is a whole-atlas clone +-- built from the ORIGINAL grayscale atlas, not worth recoloring for a rare, +-- gameplay-gated blur -- it is skipped under gbc, same as the buildAnim +-- caller already does for a texture-build failure (the static, correctly- +-- colored tile shows through unanimated). +local function buildAnim(spec, tilesetImagePath, perRow, quads, gbc) local tiles = spec.tiles if not tiles then if spec.tile == nil then return nil end tiles = { spec.tile } end local period = spec.period or ANIM_PERIOD + local colors + if gbc then + local group = PaletteFX.worldGroupAt(gbc.tilesetId, gbc.mapId, tiles[1]) + colors = group and gbc.groupColors[group + 1] + end if spec.kind == "hshift" then local offsets = spec.offsets if not offsets or #offsets == 0 then return nil end - local textures = getShiftVariants(tilesetImagePath, perRow, tiles[1]) + local textures = getShiftVariants(tilesetImagePath, perRow, tiles[1], + colors, gbc and gbc.key) if not textures then return nil end local sequence = {} for i, offset in ipairs(offsets) do sequence[i] = offset + 1 end @@ -256,11 +301,12 @@ local function buildAnim(spec, tilesetImagePath, perRow, quads) elseif spec.kind == "frames" then local sequence = spec.sequence if not (spec.images and sequence and #sequence > 0) then return nil end - local textures = getFrameImages(spec.images) + local textures = getFrameImages(spec.images, colors, gbc and gbc.key) if not textures then return nil end return { tiles = tiles, textures = textures, sequence = sequence, period = period } elseif spec.kind == "toggle" then + if gbc then return nil end local image = getToggleImage(spec, tilesetImagePath, perRow) if not image then return nil end -- the patch texture is a whole-atlas clone, so each cell needs the @@ -271,10 +317,82 @@ local function buildAnim(spec, tilesetImagePath, perRow, quads) return nil end -function TileRenderer.new(map) +-- True GBC overworld coloring (COLORS=RED++): recolor the WHOLE tileset +-- atlas once, per (tileset image, map), rather than trying to retrofit the +-- SGB zone/shade-remap-shader post-process (built for a handful of coarse +-- screen regions) into per-tile precision -- pokered-gbc's real model is +-- "one of 8 four-color BG palettes baked per tile GRAPHIC" +-- (color/loadpalettes.asm LoadTilesetPalette), which is exactly a +-- recolored atlas, not a shader pass. Every existing draw path (batches, +-- quads, border fill) then just works unmodified, with no shader at +-- draw time; OverworldState.sgbWorldZones skips the shade-remap zone pass +-- entirely when this is active (re-running it over already-true-color +-- pixels would corrupt them), and SpriteRenderer's own OBP bake composites +-- on top with ordinary alpha blending -- no trueColor exemption needed, +-- because there is no shader left for it to be exempted from. +-- +-- Only the ROOF group (index 6, OVERWORLD/PLATEAU only) varies by town +-- (LoadTownPalette); Route 6's mid-map Saffron-roof y<2 split is not +-- reproduced (it would need a rebuild on crossing the boundary for two +-- tile-rows of one route -- not worth the complexity), so it bakes with +-- the route's own default roof (Vermilion's) throughout. +local gbcAtlasCache = {} + +local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data) + local key = imagePath .. "#gbc:" .. mapId + if gbcAtlasCache[key] ~= nil then return gbcAtlasCache[key] or nil end + local img = false + if love.image and love.image.newImageData then + local groupColors = PaletteFX.worldGroupColors(data, tilesetId, mapId, nil) + if groupColors then + local src = Assets.imageData(imagePath) + local iw, ih = src:getDimensions() + local out = love.image.newImageData(iw, ih) + local tileColors = {} + for t = 0, (iw / 8) * (ih / 8) - 1 do + local colors = tileColors[t] + if colors == nil then + local group = PaletteFX.worldGroupAt(tilesetId, mapId, t) + colors = (group and groupColors[group + 1]) or false + tileColors[t] = colors + end + local ox, oy = (t % perRow) * 8, math.floor(t / perRow) * 8 + for py = 0, 7 do + for px = 0, 7 do + local sx, sy = ox + px, oy + py + local r, g, b, a = src:getPixel(sx, sy) + r, g, b, a = recolorSample(r, g, b, a, colors) + out:setPixel(sx, sy, r, g, b, a) + end + end + end + img = love.graphics.newImage(out) + end + end + gbcAtlasCache[key] = img + return img or nil +end + +-- data: Game.data (threaded through explicitly, not required lazily, so +-- headless tests that build a map from a plain local table still work) +function TileRenderer.new(map, data) local self = setmetatable({}, TileRenderer) self.map = map + self.data = data self.image = getImage(map.tileset.image) + local gbcCtx + if data and PaletteFX.usesGbcPack() and PaletteFX.hasWorldTileset(map.tileset.id) then + local gbc = getGbcAtlas(map.tileset.image, map.tileset.id, map.id, + map.tileset.tilesPerRow, data) + if gbc then + self.image = gbc + self.gbcAtlas = true + -- also recolors the animated water/flower entries below, so they + -- match the atlas's static tiles instead of showing raw grayscale + gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = "#gbc:" .. map.id, + groupColors = PaletteFX.worldGroupColors(data, map.tileset.id, map.id, nil) } + end + end -- a full-color atlas colors everything it paints, ring and border fill -- included, so every draw entry point claims its rect out of the pass self.trueColor = map.tileset.trueColor or nil @@ -301,7 +419,7 @@ function TileRenderer.new(map) local declared = map.tileset.animatedTiles or TileRenderer.defaultAnimatedTiles(map.tileset) for _, spec in ipairs(declared) do - local anim = buildAnim(spec, map.tileset.image, perRow, self.quads) + local anim = buildAnim(spec, map.tileset.image, perRow, self.quads, gbcCtx) if anim then anim.cells = {} anims[#anims + 1] = anim @@ -487,7 +605,10 @@ end -- rebuild after a block change (Cut trees) function TileRenderer:rebuild() - local fresh = TileRenderer.new(self.map) + local fresh = TileRenderer.new(self.map, self.data) + self.image = fresh.image + self.gbcAtlas = fresh.gbcAtlas + self.quads = fresh.quads self.ringBatch = fresh.ringBatch self.mapBatch = fresh.mapBatch self.anims = fresh.anims diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 091a9962..2a68f0a5 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -3,7 +3,7 @@ -- (cycles the merged rulesets registry; gen1_faithful keeps the original -- quirks), plus the port's audio rows and display rows: music/SFX -- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT / --- GBC FX, and the MODS row that opens the mod manager. +-- GBC FX / VIDEO MODE, and the MODS row that opens the mod manager. -- Rows are descriptors fed through the ui.options.rows hook, so mods can -- add their own; CANCEL is appended after the hook and stays fixed on the -- bottom line like pokered's. @@ -12,6 +12,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 VideoMode = require("src.core.VideoMode") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local OptionRows = require("src.ui.OptionRows") @@ -191,6 +192,16 @@ local function buildRows(game) GBCFX.setLevel(o.gbcfx) return true end }, + { id = "videoMode", label = "VIDEO MODE", + value = function(g) + return VideoMode.modeLabel(g.save.options.videoMode) + end, + step = function(g, dir) + local o = g.save.options + o.videoMode = VideoMode.cycle(o.videoMode, dir) + VideoMode.apply(o.videoMode) + 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", diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index f1d8f9aa..4049c04a 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -1,7 +1,7 @@ -- Title screen (engine/movie/title.asm + engine/menus/main_menu.asm): -- the logo (or a text fallback while the asset is missing), a cycling -- Pokémon front sprite, the copyright line, and the CONTINUE / NEW GAME --- / OPTION main menu on START or A. +-- / OPTION / EXIT GAME main menu on START or A. local Font = require("src.render.Font") local Music = require("src.core.Music") @@ -165,6 +165,11 @@ function TitleState:openMenu() table.insert(items, { label = "OPTION", onSelect = function() require("src.ui.Screens").push(game, "OptionsMenu") end }) + table.insert(items, { label = "EXIT GAME", onSelect = function() + if love.event and love.event.quit then + love.event.quit() + end + end }) game.stack:push(Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = #items * 2 + 2 })) end diff --git a/src/world/Map.lua b/src/world/Map.lua index f4061a08..714653bd 100644 --- a/src/world/Map.lua +++ b/src/world/Map.lua @@ -37,8 +37,11 @@ end -- on the raw blocks, honoring the surf rule (water/shore passable only -- while surfing, same fallbacks as Map.new). function Map.defPassable(def, tilesetDef, cx, cy, surfing) + -- Fail closed: a missing tileset used to return true and re-open the + -- Pallet south-shore stranding (cross onto ROUTE_21 solids). No data + -- means we cannot prove the landing is safe, so the step bumps. if not (def and tilesetDef and tilesetDef.blocks and tilesetDef.walkable) then - return true -- no data to judge with: keep the old permissive behavior + return false end local tx, ty = cx * 2, cy * 2 + 1 local bx, by = math.floor(tx / 4), math.floor(ty / 4) diff --git a/src/world/MapLoader.lua b/src/world/MapLoader.lua index 49760805..043e3ca3 100644 --- a/src/world/MapLoader.lua +++ b/src/world/MapLoader.lua @@ -23,7 +23,7 @@ function MapLoader.load(data, mapId) -- warp tiles are stored per tileset macro name; the generated tilesets -- module carries them in the tileset entry itself local map = Map.new(def, tilesetDef) - map.renderer = TileRenderer.new(map) + map.renderer = TileRenderer.new(map, data) cache[mapId] = map return map end diff --git a/src/world/NPC.lua b/src/world/NPC.lua index 395e4469..b94e83a0 100644 --- a/src/world/NPC.lua +++ b/src/world/NPC.lua @@ -26,7 +26,7 @@ function NPC.new(data, mapId, objDef) self.id = string.format("%s_obj_%d", mapId, objDef.index) local spriteDef = data.sprites[objDef.sprite] assert(spriteDef, "unknown sprite " .. tostring(objDef.sprite)) - self.sprite = SpriteRenderer.new(spriteDef) + self.sprite = SpriteRenderer.new(spriteDef, self.id) -- object_event coordinates are already walk-grid cells self.cellX, self.cellY = objDef.x, objDef.y self.px, self.py = self.cellX * 16, self.cellY * 16 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index fb7de260..199dbde8 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -388,8 +388,25 @@ end -- which recolored the whole screen per map -- see the survey zoom -- entry in docs/known-differences.md). Border fill inherits the -- current map's palette. +-- +-- RED++ true overworld coloring does NOT go through this zone/shader +-- system at all: TileRenderer bakes real per-tile GBC colors straight into +-- a recolored tileset atlas (see TileRenderer's gbcAtlas), and +-- SpriteRenderer bakes sprites' OBP colors the same way, so the world +-- canvas is already final RGB by the time this runs. Returning an EMPTY +-- list here (when the current map has that baked atlas) skips the shader +-- entirely -- Renderer:endFrame's blit sees zoneList[1] == nil and falls +-- back to a plain, unshaded draw. Returning plain `nil` would NOT do this: +-- endFrame treats a nil worldZones as "no world-specific zones, reuse the +-- UI pass's zones" (sgbPalettes' whole-screen named-palette zone), which +-- would re-run the DMG shade-remap over already-true-color pixels using +-- an unrelated 4-color palette -- exactly the "colors are wrong" bug this +-- fixes. function OverworldState:sgbWorldZones() local PaletteFX = require("src.render.PaletteFX") + if PaletteFX.usesGbcPack() and self.map.renderer and self.map.renderer.gbcAtlas then + return {} + end local base = PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) if not base then return nil end local vw, vh = Game.renderer:worldViewSize() @@ -2023,6 +2040,8 @@ function OverworldState:engageTrainer(npc, onDone) end -- Badges/items awarded after specific battles (data/scripts/victories.lua). +-- `deactivate` retires unfought gym/dojo trainers the way the originals' +-- SetEvent / SetEventRange do after the leader victory. function OverworldState:checkVictoryRewards(trainerClass, partyIndex) local victories = require("data.scripts.victories") local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)] @@ -2031,6 +2050,11 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex) if Game.save.flags[reward.flag] then return self:runVictoryHook() end Game.save.flags[reward.flag] = true end + if reward.deactivate then + for _, flag in ipairs(reward.deactivate) do + Game.save.flags[flag] = true + end + end local lines = {} if reward.badge then Game.save.inventory[reward.badge] = 1 diff --git a/src/world/Player.lua b/src/world/Player.lua index 0fe5093c..483d2ca2 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -26,12 +26,12 @@ function Player.new(data, cx, cy, facing) local walkId = FieldDefaults.fieldValue(data, "playerSprites", "walk") local surfId = FieldDefaults.fieldValue(data, "playerSprites", "surf") local bikeId = FieldDefaults.fieldValue(data, "playerSprites", "bike") - self.sprite = SpriteRenderer.new(data.sprites[walkId]) + self.sprite = SpriteRenderer.new(data.sprites[walkId], "player") if surfId and data.sprites[surfId] then - self.surfSprite = SpriteRenderer.new(data.sprites[surfId]) + self.surfSprite = SpriteRenderer.new(data.sprites[surfId], "player") end if bikeId and data.sprites[bikeId] then - self.bikeSprite = SpriteRenderer.new(data.sprites[bikeId]) + self.bikeSprite = SpriteRenderer.new(data.sprites[bikeId], "player") end -- the ledge-hop shadow quarter-tile (gfx/overworld/shadow.png, -- LedgeHoppingShadow, engine/overworld/ledges.asm) diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index f023ead6..b8705bef 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -205,7 +205,7 @@ end local om = OptionsMenu.new(optGame()) local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset", "musicVol", "sfxVol", "musicFilter", "colors", "tilt", - "gbcfx", "speed", "mods", "controls" } + "gbcfx", "videoMode", "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[12].activate(mgGame) +om.rows[13].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[13].activate(cbGame) +om.rows[14].activate(cbGame) local bm = cbGame.stack:top() check(getmetatable(bm) == BindingsMenu, "the CONTROLS row opens the rebind list") diff --git a/tests/parity_A.lua b/tests/parity_A.lua index 3e82b6ea..e082401f 100644 --- a/tests/parity_A.lua +++ b/tests/parity_A.lua @@ -263,4 +263,103 @@ do package.loaded["src.render.TextBox"] = realTB end +-- (7) gym / dojo leader victory deactivates unfought non-leader trainers +-- (PewterGym.asm "; deactivate gym trainers" / SetEventRange in the +-- other gyms; FightingDojo.asm SetEventRange through TRAINER_3). +do + local victories = require("data.scripts.victories") + local expected = { + ["OPP_BROCK#1"] = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" }, + ["OPP_MISTY#1"] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_0", + "EVENT_BEAT_CERULEAN_GYM_TRAINER_1" }, + ["OPP_LT_SURGE#1"] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_0", + "EVENT_BEAT_VERMILION_GYM_TRAINER_1", + "EVENT_BEAT_VERMILION_GYM_TRAINER_2" }, + ["OPP_ERIKA#1"] = 7, + ["OPP_KOGA#1"] = 6, + ["OPP_SABRINA#1"] = 7, + ["OPP_BLAINE#1"] = 7, + ["OPP_GIOVANNI#3"] = 8, + ["OPP_BLACKBELT#1"] = 4, + } + for key, want in pairs(expected) do + local d = victories[key] and victories[key].deactivate + check(d ~= nil, key .. " lists trainers to deactivate") + if type(want) == "number" then + eq(#d, want, key .. " deactivates " .. want .. " trainers") + else + for i, flag in ipairs(want) do + eq(d[i], flag, key .. " deactivate[" .. i .. "]") + end + end + end + + -- Cinnabar trainers have no extracted def_trainers; seeded headers let + -- EVENT_BEAT_CINNABAR_GYM_TRAINER_* satisfy trainerDefeated without + -- stamping defeatedTrainers (which would also open the quiz gates). + Data:seedCinnabarGymTrainerHeaders() + check(Data.trainer_headers.CinnabarGym ~= nil, + "CinnabarGym trainer headers seeded for deactivate flags") + local nerd1 = Data:trainerHeader("CinnabarGym", 2) + check(nerd1 and nerd1.event == "EVENT_BEAT_CINNABAR_GYM_TRAINER_0" + and nerd1.range == 0, + "Cinnabar SUPER_NERD1 header: event + no sight range") + + require("src.render.Font").load(Data) + local Game = require("src.core.Game") + local Input = require("src.core.Input") + local StateStack = require("src.core.StateStack") + local Renderer = require("src.render.Renderer") + local SaveData = require("src.core.SaveData") + local OW = require("src.world.OverworldController") + local realTB = package.loaded["src.render.TextBox"] + package.loaded["src.render.TextBox"] = { + new = function(game, text, done) return { text = text, onDone = done } end, + } + Game.data = Data + Game.input = Input; Input:init() + Game.renderer = Renderer; Renderer:init() + Game.stack = StateStack; StateStack:init() + while Game.stack:top() do Game.stack:pop() end + Game.save = SaveData.newGame() + Game.save.flags = {} + Game.save.inventory = {} + Game.save.defeatedTrainers = {} + Game.stack:push(OW, "PEWTER_GYM", 4, 13, "up") + local ow = Game.stack:top() + ow:checkVictoryRewards("OPP_BROCK", 1) + check(Game.save.flags.EVENT_BEAT_BROCK, "Brock victory sets EVENT_BEAT_BROCK") + check(Game.save.inventory.BOULDERBADGE == 1, "Brock victory awards BOULDERBADGE") + check(Game.save.flags.EVENT_BEAT_PEWTER_GYM_TRAINER_0, + "Brock victory deactivates the Pewter Gym Cooltrainer") + local pewterNpc + for _, npc in ipairs(ow.npcs) do + if npc.def.index == 2 then pewterNpc = npc break end + end + check(pewterNpc and ow:trainerDefeated(pewterNpc), + "unfought Pewter gym trainer is defeated after badge") + + while Game.stack:top() do Game.stack:pop() end + Game.save = SaveData.newGame() + Game.save.flags = {} + Game.save.inventory = {} + Game.save.defeatedTrainers = {} + Game.stack:push(OW, "CINNABAR_GYM", 16, 15, "up") + ow = Game.stack:top() + ow:checkVictoryRewards("OPP_BLAINE", 1) + check(Game.save.flags.EVENT_BEAT_CINNABAR_GYM_TRAINER_3, + "Blaine victory deactivates Cinnabar quiz trainers") + check(next(Game.save.defeatedTrainers) == nil, + "Blaine deactivate does not stamp defeatedTrainers (gates stay put)") + local cinnabarNpc + for _, npc in ipairs(ow.npcs) do + if npc.def.index == 5 then cinnabarNpc = npc break end + end + check(cinnabarNpc and ow:trainerDefeated(cinnabarNpc), + "unfought Cinnabar trainer is defeated via seeded header event") + + while Game.stack:top() do Game.stack:pop() end + package.loaded["src.render.TextBox"] = realTB +end + S.finish() diff --git a/tests/parity_connection_collision.lua b/tests/parity_connection_collision.lua new file mode 100644 index 00000000..9ac5bd87 --- /dev/null +++ b/tests/parity_connection_collision.lua @@ -0,0 +1,95 @@ +-- Regression: map-edge crossings must read the NEIGHBOR map's collision. +-- +-- Without that read, Pallet Town's south shore land spit (cells x=2,3 at +-- y=17) walked straight onto ROUTE_21 (2,0)/(3,0) -- solid tiles. From +-- (2,0) a further step south lands on walkable (2,1), after which the +-- solid edge cell blocks the only path back to the seam, so the player +-- cannot return north. pokered's CollisionCheckOnLand reads the neighbor +-- strip's tile bytes and bumps; the port must do the same via +-- Map.defPassable before crossConnection commits. +-- +-- Self-contained; run via `luajit tests/parity_connection_collision.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.PALLET_TOWN) then Data:load() end + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local Map = require("src.world.Map") +local MapLoader = require("src.world.MapLoader") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local OW = require("src.world.OverworldController") +local S = require("tests.harness").suite("parity connection collision") +local check, eq = S.check, S.eq + +local pallet = MapLoader.load(Data, "PALLET_TOWN") +local route21 = MapLoader.load(Data, "ROUTE_21") +local ts = Data.tilesets[route21.def.tileset] + +-- ground truth: the spit is land, the Route 21 landings are solid, and +-- the water column next to it is surf-only +for _, x in ipairs({ 2, 3 }) do + check(pallet:isWalkableCell(x, 17), + ("Pallet south shore (%d,17) is walkable land"):format(x)) + check(not route21:isWalkableCell(x, 0), + ("ROUTE_21 (%d,0) is a solid landing"):format(x)) + check(not Map.defPassable(route21.def, ts, x, 0, false), + ("defPassable refuses land->solid at ROUTE_21 (%d,0)"):format(x)) +end +check(route21:isWaterCell(5, 0), "ROUTE_21 (5,0) is water") +check(not Map.defPassable(route21.def, ts, 5, 0, false), + "water landing without Surf is refused") +check(Map.defPassable(route21.def, ts, 5, 0, true), + "water landing with Surf is allowed") +check(not Map.defPassable(route21.def, nil, 2, 0, false), + "missing tileset fails closed (no permissive fallback)") + +-- Live edge: standing on the spit and pressing into the seam must bump, +-- not swap maps. Pallet -> Route 1 north still crosses on grass. +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack +StateStack:init() +Game.save = SaveData.newGame() +Game.overworld = OW + +while Game.stack:top() do Game.stack:pop() end +Game.stack:push(OW, "PALLET_TOWN", 2, 17, "down") +local ow = Game.stack:top() +eq(ow.map.id, "PALLET_TOWN", "start on Pallet south shore") +eq(ow.player.cellX, 2, "spit x") +eq(ow.player.cellY, 17, "spit y") + +local conn = ow.map:connection("south") +check(conn and conn.map == "ROUTE_21", "Pallet south connects to ROUTE_21") +check(ow:crossConnection("down", conn) == false, + "crossConnection bumps on ROUTE_21 solid landing") +eq(ow.map.id, "PALLET_TOWN", "still on Pallet after the refused cross") +eq(ow.player.cellX, 2, "x unchanged after bump") +eq(ow.player.cellY, 17, "y unchanged after bump") + +-- surfing the water column still crosses +ow.player.cellX, ow.player.cellY = 5, 17 +ow.player.px, ow.player.py = 5 * 16, 17 * 16 +ow.player.surfing = true +ow.player.moving = false +check(ow:crossConnection("down", conn) == true, + "surfing Pallet water still crosses onto ROUTE_21") +eq(ow.map.id, "ROUTE_21", "surf cross lands on ROUTE_21") + +-- north seam onto Route 1 grass still works on foot +while Game.stack:top() do Game.stack:pop() end +Game.stack:push(OW, "PALLET_TOWN", 10, 0, "up") +ow = Game.stack:top() +local north = ow.map:connection("north") +check(north and north.map == "ROUTE_1", "Pallet north connects to ROUTE_1") +check(ow:crossConnection("up", north) == true, + "Pallet -> Route 1 grass crossing still allowed") +eq(ow.map.id, "ROUTE_1", "north cross lands on ROUTE_1") + +S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index ccccc589..85b08d76 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -38,6 +38,19 @@ check(pallet:isWarpTileCell(5, 5), "Red's house door is a door tile") local w = pallet:warpAtCell(5, 5) eq(w.def.destMap, "REDS_HOUSE_1F", "door warp goes to Red's house") +-- Pallet south shore spit: land that faces ROUTE_21 solids. Crossing +-- without reading the neighbor tile stranded players (see +-- tests/parity_connection_collision.lua). +local Map = require("src.world.Map") +local route21Def = Data.maps.ROUTE_21 +local route21Tileset = Data.tilesets[route21Def.tileset] +check(pallet:isWalkableCell(2, 17), "Pallet south shore spit (2,17) walkable") +check(pallet:isWalkableCell(3, 17), "Pallet south shore spit (3,17) walkable") +check(not Map.defPassable(route21Def, route21Tileset, 2, 0, false), + "ROUTE_21 (2,0) refuses a land edge cross") +check(not Map.defPassable(route21Def, route21Tileset, 3, 0, false), + "ROUTE_21 (3,0) refuses a land edge cross") + -- signs local sign = pallet:signAtCell(13, 13) eq(sign.text, "TEXT_PALLETTOWN_OAKSLAB_SIGN", "Oak's lab sign at (13,13)") @@ -1132,6 +1145,20 @@ do tb.trainer.name)), "trainer defeat uses ' defeated !'") + -- pret GetTrainerName_: rival classes show wRivalName, not "RIVAL1" + do + local savedRival = Game.save.player.rival + Game.save.player.rival = "GARY" + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 30) } + local rb = BattleState.newTrainer(Game, "OPP_RIVAL1", 1) + eq(rb.trainer.name, "GARY", "rival battle uses saved rival name") + check(rb.introText:find("GARY", 1, true), + "rival intro wants-to-fight uses rival name") + check(Data.trainers.OPP_RIVAL1.name == "RIVAL1", + "shared trainer data keeps the RIVAL1 placeholder") + Game.save.player.rival = savedRival + end + -- PartyMenu onCancel fires when backing out without a pick do local PartyMenu = require("src.ui.PartyMenu") @@ -1999,8 +2026,8 @@ do -- option boxes (the port rows plus the MODS/CONTROLS entries) through a 4-box -- viewport with a $EE ▼ marker; MUSIC VOL / SFX VOL clamp at 0..7 like -- the text-speed cursor clamps at its ends (.pressedLeftInTextSpeed), --- MUSIC FILTER cycles OFF/1X/2X/3X, and COLORS / TILT / GBC FX cycle --- their display modes. +-- MUSIC FILTER cycles OFF/1X/2X/3X, and COLORS / TILT / GBC FX / VIDEO MODE +-- cycle their display modes. do local OptionsMenu = require("src.ui.OptionsMenu") local OInput = require("src.core.Input") @@ -2008,6 +2035,7 @@ do local Tilt = require("src.render.Tilt") local GBCFX = require("src.render.GBCFX") local GameSpeed = require("src.core.GameSpeed") + local VideoMode = require("src.core.VideoMode") local SD = require("src.core.SaveData") -- Isolate from earlier save/options writes in this suite SD.saveOptions(SD.defaultOptions()) @@ -2026,6 +2054,8 @@ do eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC") eq(og.save.options.tilt, 0, "new saves default TILT to OFF") eq(og.save.options.gbcfx, 0, "new saves default GBC FX to OFF") + eq(og.save.options.videoMode, "windowed", + "new saves default VIDEO MODE to WINDOWED") eq(om.scroll, 0, "options viewport starts at the top") for _ = 1, 4 do press("down") end eq(om.index, 5, "cursor reaches MUSIC VOL") @@ -2065,7 +2095,15 @@ 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 GAME SPEED") + eq(om.index, 11, "cursor reaches VIDEO MODE") + press("a") + eq(og.save.options.videoMode, "borderless", + "A cycles VIDEO MODE to BORDERLESS") + press("a") + eq(og.save.options.videoMode, "windowed", + "VIDEO MODE wraps back to WINDOWED") + press("down") + eq(om.index, 12, "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 @@ -2074,25 +2112,26 @@ do 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 MODS") + eq(om.index, 13, "cursor reaches MODS") press("down") - eq(om.index, 13, "cursor reaches CONTROLS") + eq(om.index, 14, "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") + eq(om.index, 15, "CANCEL stays the fixed final row") + eq(om.scroll, 10, "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, 14, "up from the top wraps to CANCEL") - eq(om2.scroll, 9, "wrapping to CANCEL scrolls to the tail") + eq(om2.index, 15, "up from the top wraps to CANCEL") + eq(om2.scroll, 10, "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) PaletteFX.applyOptions(og.save.options) Tilt.applyOptions(og.save.options) GBCFX.applyOptions(og.save.options) + VideoMode.applyOptions(og.save.options) end end diff --git a/tools/extract/palettes.py b/tools/extract/palettes.py index 9be7d694..6be5a70e 100644 --- a/tools/extract/palettes.py +++ b/tools/extract/palettes.py @@ -18,11 +18,44 @@ Output: data/generated/palettes.lua pokemon[SPECIES] = NAME (PAL_ prefix stripped) Output: data/palettes_gbc.lua (committed; not wiped by ROM import) - same shape, plus per-species palette entries (BULBASAUR, …) + same shape, plus per-species palette entries (BULBASAUR, …), plus a + `world` table (below) for true overworld tile/roof/sprite coloring. The HP bar fill is GB color 2 of PAL_GREENBAR / PAL_YELLOWBAR / PAL_REDBAR; the thresholds live in home/palettes.asm GetHealthBarColor (>= 27 pixels green, >= 10 yellow, else red). + +Sources (pokered-gbc color/**, the real GBC overworld coloring engine): + color/data/map_palette_assignments.asm per tileset: 96 tile-graphic-id + -> palette-group (0-7) bytes + color/data/map_palette_sets.asm per tileset: 8 group -> palette- + constant bytes (positional) + color/data/map_palettes.asm palette-constant -> 4 RGB colors + color/data/roofpalettes.asm per pokered map ID (positional, + matches map.def.index): roof + 2-color override + the labeled + RGB blocks it points at + color/data/spritepalettes.asm 8 four-color OBJ palettes + color/sprites.asm SpritePaletteAssignments: 1-based + picture ID -> OBJ palette 0-3, or + the "db 4" random-per-instance + sentinel (ColorOverworldSprite) + +Output `world` table shape: + world.tileGroups[TILESET][tileId 0-95] = group index 0-7 + world.groupColors[TILESET][group 0-7] = { {r,g,b} x4 } + world.roofGroup[TILESET] = the town-overridable group index (OVERWORLD/ + PLATEAU only) + world.roofByMapIndex[mapIndex] = { {r,g,b} x2 } (matches map.def.index) + world.spritePalettes[0-7] = { {r,g,b} x4 } + world.spriteAssignment[pictureIndex 0-based] = group 0-3 or "random" + (pictureIndex matches the N in a sprite def's + `source = "ROM:SpriteSheetPointerTable[N]"`) + +The 3 hardcoded Celadon Mart tile-group exceptions and the Route 6/Saffron +roof y-split (LoadTilesetPalette / LoadTownPalette control flow, not data) +are NOT extracted here -- they live as a small named table in +src/render/PaletteFX.lua next to the code that consumes `world`. """ import argparse @@ -185,6 +218,271 @@ def _parse_gbc_monster_palettes(path): return mon_pals +# ---- color/** overworld attribute data ------------------------------------ +# +# Two tiny fixed enums, hand-transcribed rather than parsed: both are +# `EQU`/`const` lists that never change (they are the hardware-shaped slot +# numbering, not game content), and each uses a different const-declaration +# style that isn't worth a bespoke parser for 4-8 entries. +# color/data/map_palette_constants.asm:69-78 (`const_value = 0` block) +_GROUP_NAMES = ["GRAY", "RED", "GREEN", "BLUE", "YELLOW", "BROWN", "ROOF", "TEXT"] +_GROUP_INDEX = {name: i for i, name in enumerate(_GROUP_NAMES)} +# color/sprites.asm:15-18 (`SPR_PAL_* EQU n`) +_SPR_PAL = {"SPR_PAL_ORANGE": 0, "SPR_PAL_BLUE": 1, "SPR_PAL_GREEN": 2, "SPR_PAL_BROWN": 3} + + +def _rgbN(nums): + return [[_scale5(nums[i]), _scale5(nums[i + 1]), _scale5(nums[i + 2])] + for i in range(0, len(nums), 3)] + + +def _parse_rgb_table(path, header_re, n_nums): + """name -> n_nums/3 RGB colors, from blocks of `header_re` + RGB lines + (one or more `RGB r,g,b[,r,g,b...]` lines per block, however they're + wrapped -- matches both the 1-color-per-line and 4-colors-per-line + styles pokered-gbc uses in different files).""" + out = {} + order = [] + pending = None + buf = [] + for lineno, line in _read_raw(path): + s = line.strip() + m = header_re.match(s) + if m: + if pending: + util.die(f"{path}:{lineno}: incomplete RGB block for " + f"{pending} ({len(buf)}/{n_nums})") + pending = m.group(1) + buf = [] + continue + m = re.match(r"RGB\s+([\d,\s]+)", s) + if not m or not pending: + continue + nums = [n for n in re.split(r"[,\s]+", m.group(1).strip()) if n] + buf.extend(nums) + if len(buf) < n_nums: + continue + if len(buf) != n_nums: + util.die(f"{path}:{lineno}: expected {n_nums} components for " + f"{pending}, got {len(buf)}") + out[pending] = _rgbN(buf) + order.append(pending) + pending = None + buf = [] + if pending: + util.die(f"{path}: incomplete RGB block for {pending}") + return out, order + + +_TILESET_HEADER_RE = re.compile(r"^;\s*([A-Z][A-Z0-9_]*)\s*$") + + +def _parse_map_palette_assignments(path): + """color/data/map_palette_assignments.asm: per tileset, 96 tile-graphic + palette-group bytes (0-7, symbolic), 16 per line / 6 lines.""" + tile_groups = {} + order = [] + current = None + vals = [] + + def flush(lineno): + if len(vals) != 96: + util.die(f"{path}:{lineno}: {current} has {len(vals)} tile " + f"groups (want 96)") + tile_groups[current] = vals[:] + order.append(current) + + for lineno, line in _read_raw(path): + s = line.strip() + m = _TILESET_HEADER_RE.match(s) + if m: + if current: + flush(lineno) + current, vals = m.group(1), [] + continue + m = re.match(r"db\s+(.*)$", s) + if not m or not current: + continue + for tok in m.group(1).split(","): + tok = tok.strip() + if tok not in _GROUP_INDEX: + util.die(f"{path}:{lineno}: unknown palette group {tok!r}") + vals.append(_GROUP_INDEX[tok]) + if current: + flush("EOF") + return tile_groups, order + + +def _parse_map_palette_sets(path): + """color/data/map_palette_sets.asm: per tileset, 8 palette-constant + names, positional (index = the palette-group 0-7 above).""" + sets = {} + order = [] + current = None + vals = [] + + def flush(lineno): + if len(vals) != 8: + util.die(f"{path}:{lineno}: {current} has {len(vals)} palette " + f"sets (want 8)") + sets[current] = vals[:] + order.append(current) + + for lineno, line in _read_raw(path): + s = line.strip() + m = _TILESET_HEADER_RE.match(s) + if m: + if current: + flush(lineno) + current, vals = m.group(1), [] + continue + m = re.match(r"db\s+(\w+)", s) + if m and current: + vals.append(m.group(1)) + if current: + flush("EOF") + return sets, order + + +def _parse_roof_order(path): + """color/data/roofpalettes.asm's RoofPalettes: dw list, positional by + pokered map ID (matches map.def.index in the recomp project).""" + names = [] + in_table = False + for lineno, line in _read_raw(path): + s = line.strip() + if s.startswith("RoofPalettes:"): + in_table = True + continue + if not in_table: + continue + m = re.match(r"dw\s+(\w+)", s) + if m: + names.append(m.group(1)) + continue + if s == "": + continue + break # first non-dw, non-blank line (a Label:) ends the table + if not names: + util.die(f"{path}: RoofPalettes table not found or empty") + return names + + +def _parse_sprite_assignments(path): + """color/sprites.asm SpritePaletteAssignments: 1-based picture ID (from + the '; 0xNN: SPRITE_NAME' comment) -> OBJ palette 0-3, or "random" for + the "db 4" per-instance sentinel (ColorOverworldSprite). Returned keyed + 0-based (pictureId - 1), matching a recomp sprite def's + `source = "ROM:SpriteSheetPointerTable[N]"` index.""" + assignments = {} + in_table = False + pending_id = None + header_re = re.compile(r"^;\s*0x([0-9a-fA-F]+):") + for lineno, line in _read_raw(path): + s = line.strip() + if s.startswith("SpritePaletteAssignments:"): + in_table = True + continue + if not in_table: + continue + m = header_re.match(s) + if m: + pending_id = int(m.group(1), 16) + continue + m = re.match(r"db\s+(\w+)", s) + if m and pending_id is not None: + tok = m.group(1) + if tok == "4": + assignments[pending_id - 1] = "random" + elif tok in _SPR_PAL: + assignments[pending_id - 1] = _SPR_PAL[tok] + else: + util.die(f"{path}:{lineno}: unknown sprite palette {tok!r}") + pending_id = None + continue + if s and not s.startswith(";"): + break # next label (AnimationTileset1Palettes:) ends the table + if not assignments: + util.die(f"{path}: SpritePaletteAssignments not found or empty") + return assignments + + +def extract_gbc_world(pokered_gbc): + """pokered-gbc color/** -> the `world` table (see module docstring): + real per-tile GBC BG-palette groups, per-town roof overrides, and + overworld sprite OBJ palettes, for true overworld parity under + COLORS=RED++ (LoadTilesetPalette / LoadTownPalette / ColorOverworldSprite).""" + base = os.path.join(pokered_gbc, "color") + + tile_groups, ts_order = _parse_map_palette_assignments( + os.path.join(base, "data/map_palette_assignments.asm")) + pal_sets, ts_order2 = _parse_map_palette_sets( + os.path.join(base, "data/map_palette_sets.asm")) + if set(ts_order) != set(ts_order2): + util.die("map_palette_assignments.asm / map_palette_sets.asm " + "tileset lists disagree") + if len(ts_order) < 20: + util.die(f"map_palette_assignments.asm: parsed only {len(ts_order)} " + f"tilesets (want ~24)") + + map_palettes, _ = _parse_rgb_table( + os.path.join(base, "data/map_palettes.asm"), + re.compile(r"^;\s*0x[0-9a-fA-F]+:\s*(\w+)\s*$"), 12) + + group_colors = {} + for ts in ts_order: + colors = [] + for slot, const in enumerate(pal_sets[ts]): + if const not in map_palettes: + util.die(f"map_palette_sets.asm: {ts} slot {slot}: unknown " + f"palette constant {const}") + colors.append(map_palettes[const]) + group_colors[ts] = colors + + # Only these two tilesets are ever town-roof-swapped + # (LoadTilesetPalette: `cp 0` / `cp PLATEAU` before calling + # LoadTownPalette) -- the ROOF slot for every other tileset is static. + roof_group = {ts: _GROUP_INDEX["ROOF"] + for ts in ("OVERWORLD", "PLATEAU") if ts in group_colors} + + roof_path = os.path.join(base, "data/roofpalettes.asm") + roof_colors, _ = _parse_rgb_table( + roof_path, re.compile(r"^(?!RoofPalettes)(\w+):\s*$"), 6) + roof_order = _parse_roof_order(roof_path) + roof_by_index = {} + for i, name in enumerate(roof_order): + if name not in roof_colors: + util.die(f"{roof_path}: RoofPalettes references unknown label " + f"{name}") + roof_by_index[i] = roof_colors[name] + + sprite_path = os.path.join(base, "data/spritepalettes.asm") + raw_sprite_pals, _ = _parse_rgb_table( + sprite_path, re.compile(r"^;\s*(\d+)\s*$"), 12) + sprite_palettes = {int(k): v for k, v in raw_sprite_pals.items()} + if len(sprite_palettes) != 8: + util.die(f"{sprite_path}: parsed {len(sprite_palettes)} sprite " + f"palettes (want 8)") + + sprite_assignment = _parse_sprite_assignments( + os.path.join(base, "sprites.asm")) + if len(sprite_assignment) != 72: + util.die(f"sprites.asm: parsed {len(sprite_assignment)} sprite " + f"palette assignments (want 72)") + + tile_groups_out = {ts: {i: v for i, v in enumerate(vals)} + for ts, vals in tile_groups.items()} + + return { + "tileGroups": tile_groups_out, + "groupColors": group_colors, + "roofGroup": roof_group, + "roofByMapIndex": roof_by_index, + "spritePalettes": sprite_palettes, + "spriteAssignment": sprite_assignment, + } + + def extract_gbc(pokered_gbc, out_path): """Build the Red++ / pokered-gbc SuperPalette pack used by COLORS=RED++.""" super_path = os.path.join(pokered_gbc, "data/super_palettes.asm") @@ -218,17 +516,23 @@ def extract_gbc(pokered_gbc, out_path): if name not in palettes: util.die(f"super_palettes.asm: PAL_{name} missing") + world = extract_gbc_world(pokered_gbc) + # write_lua stamps "Generated by tools/build_data.py"; keep that header # but point the source field at the gbc tree. util.write_lua( out_path, - {"source": "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm", + {"source": "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm" + " + color/**", "palettes": palettes, "order": order, - "pokemon": mon_pals}, + "pokemon": mon_pals, + "world": world}, header="Red++ / pokered-gbc SuperPalettes (COLORS=RED++): 4 8-bit RGB\n" "colors per palette (color 0 first), including per-species\n" - "pals and the GEN_2 MonsterPalettes assignment.") + "pals and the GEN_2 MonsterPalettes assignment, plus `world`\n" + "(true overworld tile/roof/sprite GBC coloring -- see this\n" + "file's module docstring for its shape).") return palettes, mon_pals