"""Extract field-interaction data: ledges, cut trees, water tilesets. Sources: data/tilesets/ledge_tiles.asm -> hop rules (facing, stand, ledge, pad) data/tilesets/cut_tree_blocks.asm -> block swap after Cut data/tilesets/water_tilesets.asm -> tilesets where Surf works data/events/hidden_events.asm -> hidden items / coins / slot machines, PC tiles, bench guys, gym statues, PrintTrashText bins, Vermilion Gym trash cans data/events/bench_guys.asm -> bench guy text per map data/events/slot_machine_wheels.asm -> the three slot wheel symbol lists data/events/card_key_{coords,maps}.asm + engine/events/card_key.asm -> Silph Co card key doors data/maps/force_bike_surf.asm + home/overworld.asm -> forced bike/surf tiles, Cycling Road scripts/SeafoamIslandsB{2,3,4}F.asm -> surf currents and boulder holes scripts/GameCorner.asm -> Rocket Hideout poster block swap scripts/Route22Gate.asm, scripts/Route23.asm -> badge gates constants/player_constants.asm -> preset player/rival names engine/events/hidden_events/vermilion_gym_trash.asm -> trash can puzzle scripts/{ViridianGym,RocketHideoutB2F,RocketHideoutB3F}.asm -> spinner arrow tile movement tables gfx/title/*.png, gfx/splash/copyright.png -> title screen assets (via gfx.py) gfx/splash/*, gfx/intro/*, engine/movie/{splash,intro}.asm -> intro movie assets (via gfx.py) data/maps/{town_map_entries,town_map_order,names}.asm -> town map locations + cursor order data/credits/*.asm + engine/movie/credits.asm -> end credits + THE END constants/script_constants.asm + gfx/slots/*, gfx/emotes/* -> slot wheel symbols, emotion bubbles scripts/ViridianCity.asm -> old man catch-demo battle scripts/GameCorner.asm + text/GameCorner.asm -> coin purchases constants/menu_constants.asm -> PC item capacity Output: data/generated/field.lua (+ assets/generated/{title,slots,credits}/*.png, assets/generated/emotes.png) """ import os import re from . import gfx, text, util from .util import parse_number, read_asm, split_args DIRS = { "SPRITE_FACING_DOWN": "down", "SPRITE_FACING_UP": "up", "SPRITE_FACING_LEFT": "left", "SPRITE_FACING_RIGHT": "right", } def parse_fly_warps(pokered): """data/maps/special_warps.asm: fly_warp MAP, x, y landing spots.""" warps = {} order = [] for lineno, line in read_asm(os.path.join(pokered, "data/maps/special_warps.asm")): m = re.match(r"\.?\w*:?\s*fly_warp\s+(\w+),\s*(\d+),\s*(\d+)", line.strip()) if m: warps[m.group(1)] = {"x": int(m.group(2)), "y": int(m.group(3))} order.append(m.group(1)) return warps, order def parse_super_rod(pokered): """data/wild/super_rod.asm: map -> fishing group of (level, species).""" groups = {} per_map = [] current = None for lineno, line in read_asm(os.path.join(pokered, "data/wild/super_rod.asm")): s = line.strip() m = re.match(r"dbw\s+(\w+),\s*\.(\w+)", s) if m: per_map.append((m.group(1), m.group(2))) continue m = re.match(r"\.(\w+):?\s*$", s) if m: current = m.group(1) groups[current] = [] continue m = re.match(r"db\s+(\d+),\s*(\w+)$", s) if m and current: groups[current].append({"level": int(m.group(1)), "species": m.group(2)}) out = {} for map_id, group in per_map: out[map_id] = groups.get(group, []) return out def parse_trades(pokered): """data/events/trades.asm: npctrade give, get, dialogset, nickname.""" # TRADE_DIALOGSET_* order (constants/script_constants.asm) indexes # InGameTradeTextPointers -> TradeTextPointers1/2/3 # (engine/events/in_game_trades.asm); stored 1-based to match the # _WannaTradeText/_AfterTradeText/... label numbering. dialogsets = { "TRADE_DIALOGSET_CASUAL": 1, "TRADE_DIALOGSET_EVOLUTION": 2, "TRADE_DIALOGSET_HAPPY": 3, } trades = [] for lineno, line in read_asm(os.path.join(pokered, "data/events/trades.asm")): m = re.match(r'npctrade\s+(\w+),\s*(\w+),\s*(\w+),\s*"([^"]*)"', line.strip()) if m: trades.append({ "give": m.group(1), # what the NPC wants from the player "get": m.group(2), # what the NPC hands over "dialogset": dialogsets.get(m.group(3), 1), "nickname": m.group(4), }) return trades def parse_hidden_events(pokered): """data/events/hidden_events.asm: per-map hidden_event x, y, Func, arg. Keeps the three data-driven kinds: HiddenItems (item pickups the Itemfinder detects), HiddenCoins (Game Corner floor coins) and StartSlotMachine (slot machine seats; arg SLOTS_* marks broken ones). Also collects the engine text hooks that the port implements natively: OpenPokemonCenterPC / OpenRedsPC (the player's storage PC in the bedroom), PrintBenchGuyText, GymStatues, PrintTrashText (plain "only trash here" bins) and the Vermilion Gym GymTrashScript cans (arg = [wGymTrashCanIndex]). For those the fourth macro argument is the facing direction required to trigger the event, except GymTrashScript where it is the can index and PrintTrashText which stores a facing but ignores it. """ items = {} coins = {} slots = {} extras = { "pcTiles": {}, "benchGuys": {}, "gymStatues": {}, "printTrash": {}, "trashCans": [], } current = None path = os.path.join(pokered, "data/events/hidden_events.asm") for lineno, line in read_asm(path): s = line.strip() m = re.match(r"hidden_events_for\s+(\w+)", s) if m: current = m.group(1) continue m = re.match(r"hidden_event\s+(\d+),\s*(\d+),\s*(\w+),\s*(.+)$", s) if not m or not current: continue x, y, func, arg = int(m.group(1)), int(m.group(2)), m.group(3), m.group(4).strip() if func == "HiddenItems": items.setdefault(current, []).append({"x": x, "y": y, "item": arg}) elif func == "HiddenCoins": cm = re.match(r"COIN\s*\+\s*(\d+)", arg) if cm: coins.setdefault(current, []).append( {"x": x, "y": y, "coins": int(cm.group(1))}) elif func == "StartSlotMachine": state = "ok" if arg == "SLOTS_OUTOFORDER": state = "out_of_order" elif arg == "SLOTS_OUTTOLUNCH": state = "out_to_lunch" elif arg == "SLOTS_SOMEONESKEYS": state = "keys" slots.setdefault(current, []).append({"x": x, "y": y, "state": state}) elif func == "OpenPokemonCenterPC" or func == "OpenRedsPC": extras["pcTiles"].setdefault(current, []).append( {"x": x, "y": y, "facing": DIRS.get(arg, arg)}) elif func == "PrintBenchGuyText": extras["benchGuys"].setdefault(current, []).append( {"x": x, "y": y, "facing": DIRS.get(arg, arg)}) elif func == "GymStatues": extras["gymStatues"].setdefault(current, []).append( {"x": x, "y": y, "facing": DIRS.get(arg, arg)}) elif func == "PrintTrashText": extras["printTrash"].setdefault(current, []).append( {"x": x, "y": y, "facing": DIRS.get(arg, arg)}) elif func == "GymTrashScript": if current != "VERMILION_GYM": util.die(f"hidden_events.asm:{lineno}: GymTrashScript outside VERMILION_GYM") extras["trashCans"].append({"x": x, "y": y, "can": int(arg)}) return items, coins, slots, extras def parse_bench_guy_texts(pokered): """data/events/bench_guys.asm: bench_guy_text map, facing, text. PrintBenchGuyText (engine/events/hidden_events/bench_guys.asm) looks up wCurMap in this table and shows the text if the player's facing matches the table entry (a table bug misaligns the scan when it does not match, e.g. VERMILION_POKECENTER triggers facing up but its entry says left). """ texts = {} path = os.path.join(pokered, "data/events/bench_guys.asm") for lineno, line in read_asm(path): m = re.match(r"bench_guy_text\s+(\w+),\s*(SPRITE_FACING_\w+),\s*(\w+)", line.strip()) if m: texts[m.group(1)] = {"facing": DIRS[m.group(2)], "text": m.group(3)} return texts def parse_trash_can_puzzle(pokered, cans): """engine/events/hidden_events/vermilion_gym_trash.asm GymTrashScript. Puzzle rules (see also scripts/VermilionCity.asm .setFirstLockTrashCanIndex): * The first switch is placed when Vermilion City loads: wFirstLockTrashCanIndex = Random & $0e, i.e. one of the 8 even-indexed cans 0,2,..,14. * Opening it sets EVENT_1ST_LOCK_OPENED; the second switch is then picked at random from the first can's row in the GymTrashCans adjacency table (byte 0 = candidate count, bytes 1-4 = candidate can indices). A signed-offset bug can make the pick fall outside the row, in which case can 0 gets the second switch. * Searching any other can resets EVENT_1ST_LOCK_OPENED, prints the fail text and rerandomizes the first can (Random & $0e again). * Finding the second switch sets EVENT_2ND_LOCK_OPENED (doors open). The 15 cans sit at x = 1,3,5,7,9 / y = 7,9,11 (five map columns of three); can index = 3*(x-1)/2 + (y-7)/2, so adjacency entries differ by 1 (vertical neighbour, same column) or 3 (horizontal neighbour). """ path = os.path.join(pokered, "engine/events/hidden_events/vermilion_gym_trash.asm") adjacency = {} in_table = False text = [] for lineno, line in read_asm(path): s = line.strip() text.append(s) if s == "GymTrashCans:": in_table = True continue if in_table: m = re.match(r"db\s+(\d+),\s*(\d+),\s*(\d+),\s*(\d+),\s*(\d+)$", s) if m: count = int(m.group(1)) row = [int(m.group(i)) for i in range(2, 6)][:count] adjacency[len(adjacency)] = row elif s: in_table = False joined = "\n".join(text) if "and $e" not in joined or "SetEvent EVENT_2ND_LOCK_OPENED" not in joined: util.die("vermilion_gym_trash.asm: puzzle randomization code changed") if len(adjacency) != 15: util.die(f"vermilion_gym_trash.asm: expected 15 GymTrashCans rows, got {len(adjacency)}") for can in cans: idx = 3 * (can["x"] - 1) // 2 + (can["y"] - 7) // 2 if idx != can["can"]: util.die(f"trash can index/coord mismatch: {can}") for adj in adjacency[can["can"]]: if abs(adj - can["can"]) not in (1, 3): util.die(f"trash can adjacency not a grid neighbour: {can['can']} -> {adj}") return { "map": "VERMILION_GYM", "cans": cans, "adjacent": adjacency, # can index -> cans that may hold switch 2 "firstLockCandidates": list(range(0, 15, 2)), # Random & $0e "firstLockEvent": "EVENT_1ST_LOCK_OPENED", "secondLockEvent": "EVENT_2ND_LOCK_OPENED", "columns": 5, "rows": 3, # physical layout; index = 3*(x-1)/2 + (y-7)/2 "rules": "first switch: random even can (Random & $0e, rolled on Vermilion City load); " "second switch: random can adjacent to the first (GymTrashCans table); " "a wrong second can relocks and rerandomizes the first switch", } def parse_slot_wheels(pokered): """data/events/slot_machine_wheels.asm: three symbol sequences.""" wheels = [] current = None path = os.path.join(pokered, "data/events/slot_machine_wheels.asm") for lineno, line in read_asm(path): s = line.strip() if re.match(r"SlotMachineWheel\d:", s): current = [] wheels.append(current) continue m = re.match(r"dw\s+SLOTS(\w+)$", s) if m and current is not None: current.append(m.group(1)) # 7, MOUSE, FISH, BAR, CHERRY, BIRD return wheels def parse_card_key_doors(pokered): """Silph Co card key doors. data/events/card_key_coords.asm lists the door tile coords as `db map, Y, X, gate id` (the three tables are unused by the engine but match the real door positions). The engine (engine/events/card_key.asm PrintCardKeyText) instead works on any map in SilphCoMapList (data/events/card_key_maps.asm): if the tile in front of the player is $18 or $24 (locked door tiles, FACILITY tileset) -- or $5e on SILPH_CO_11F -- and the player has the CARD_KEY, it halves the tile coords to block coords and replaces that block with $0e (open door; $03 on SILPH_CO_11F). """ doors = {} n_doors = 0 path = os.path.join(pokered, "data/events/card_key_coords.asm") for lineno, line in read_asm(path): m = re.match(r"db\s+(SILPH_CO_\w+),\s*(\$\w+),\s*(\$\w+),\s*(\d+)$", line.strip()) if m: doors.setdefault(m.group(1), []).append( {"x": parse_number(m.group(3)), "y": parse_number(m.group(2)), "gate": int(m.group(4))}) n_doors += 1 maps = [] for lineno, line in read_asm(os.path.join(pokered, "data/events/card_key_maps.asm")): m = re.match(r"db\s+(SILPH_CO_\w+)$", line.strip()) if m: maps.append(m.group(1)) engine = "\n".join(l.strip() for _, l in read_asm(os.path.join(pokered, "engine/events/card_key.asm"))) for needle in ("cp $18", "cp $24", "cp $5e", "ld a, $3", "ld a, $e"): if needle not in engine: util.die(f"card_key.asm: {needle!r} not found (door tiles/blocks changed?)") if n_doors != 22 or len(maps) != 10: util.die(f"card key doors: expected 22 doors / 10 maps, got {n_doors}/{len(maps)}") # closedDoors is hand-ported, not extracted: the .blk layouts ship with # these doorways open and each floor's map script stamps the closed block # on load, which the extractor cannot see from static map bytes. Kept in # sync by hand with tools/rom_manifest.json's field.cardKeyDoors.closedDoors. # # ROCKET_HIDEOUT_* are the elevator lift gates, live in retail: the # RocketHideoutB1F/B4F DoorCallbackScripts (scripts/RocketHideoutB1F.asm, # RocketHideoutB4F.asm) ReplaceTileBlock a barred facility block over the # lift doorway with floor block 0x0e until the guards are beaten. B1F # stamps 0x54 at (12,8), opened by EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4 # (EVENT_ENTERED_ROCKET_HIDEOUT is never SetEvent -- the noted SFX bug -- # so it is not part of the open condition). B4F stamps 0x2d at (12,5), # opened once BOTH EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0 and _1 are set # (CheckBothEventsSet). B2F/B3F call no door callback (spinner floors). # # SILPH_CO_* restore cut content instead: no retail .blk places a closed # block at those coords and no retail script stamps them (the feature is # unused in the original game), stamping facility.bst blocks 0x54/0x5f # (2F-10F) or interior.bst block 0x20 (11F) over each door, opened by that # door's EVENT_SILPH_CO_n_UNLOCKED_DOORn flag. closed_doors = { "ROCKET_HIDEOUT_B1F": [ {"block": 0x54, "bx": 12, "by": 8, "event": "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4", "open": 0x0e}, ], "ROCKET_HIDEOUT_B4F": [ {"block": 0x2d, "bx": 12, "by": 5, "events": ["EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0", "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1"], "open": 0x0e}, ], "SILPH_CO_2F": [ {"block": 0x54, "bx": 2, "by": 2, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR1", "open": 0x0e}, {"block": 0x54, "bx": 2, "by": 5, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR2", "open": 0x0e}, ], "SILPH_CO_3F": [ {"block": 0x5f, "bx": 4, "by": 4, "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR1", "open": 0x0e}, {"block": 0x5f, "bx": 8, "by": 4, "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR2", "open": 0x0e}, ], "SILPH_CO_4F": [ {"block": 0x54, "bx": 2, "by": 6, "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR1", "open": 0x0e}, {"block": 0x54, "bx": 6, "by": 4, "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR2", "open": 0x0e}, ], "SILPH_CO_5F": [ {"block": 0x5f, "bx": 3, "by": 2, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR1", "open": 0x0e}, {"block": 0x5f, "bx": 3, "by": 6, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR2", "open": 0x0e}, {"block": 0x5f, "bx": 7, "by": 5, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR3", "open": 0x0e}, ], "SILPH_CO_6F": [ {"block": 0x5f, "bx": 2, "by": 6, "event": "EVENT_SILPH_CO_6_UNLOCKED_DOOR", "open": 0x0e}, ], "SILPH_CO_7F": [ {"block": 0x54, "bx": 5, "by": 3, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR1", "open": 0x0e}, {"block": 0x54, "bx": 10, "by": 2, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR2", "open": 0x0e}, {"block": 0x54, "bx": 10, "by": 6, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR3", "open": 0x0e}, ], "SILPH_CO_8F": [ {"block": 0x5f, "bx": 3, "by": 4, "event": "EVENT_SILPH_CO_8_UNLOCKED_DOOR", "open": 0x0e}, ], "SILPH_CO_9F": [ {"block": 0x5f, "bx": 1, "by": 4, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR1", "open": 0x0e}, {"block": 0x54, "bx": 9, "by": 2, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR2", "open": 0x0e}, {"block": 0x54, "bx": 9, "by": 5, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR3", "open": 0x0e}, {"block": 0x5f, "bx": 5, "by": 6, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR4", "open": 0x0e}, ], "SILPH_CO_10F": [ {"block": 0x54, "bx": 5, "by": 4, "event": "EVENT_SILPH_CO_10_UNLOCKED_DOOR", "open": 0x0e}, ], "SILPH_CO_11F": [ {"block": 0x20, "bx": 3, "by": 6, "event": "EVENT_SILPH_CO_11_UNLOCKED_DOOR", "open": 0x03}, ], } return { "maps": maps, # maps where the engine checks for doors "doors": doors, # tile coords; block coord = floor(coord/2) "doorTiles": [0x18, 0x24], # locked-door tile ids (FACILITY tileset) "openBlock": 0x0e, # block written over the door's block "silphCo11F": {"doorTile": 0x5e, "openBlock": 0x03}, "closedDoors": closed_doors, } def parse_forced_movement(pokered): """data/maps/force_bike_surf.asm + the Cycling Road engine handling. CheckForceBikeOrSurf (engine/overworld/player_state.asm) walks ForcedBikeOrSurfMaps; on ROUTE_16/ROUTE_18 entries it forces the bike (wWalkBikeSurfState = 1), on the SEAFOAM_ISLANDS entries it forces surfing (state 2) and kicks off the map's MOVE_OBJECT current script. Cycling Road slope (slopeMaps): JoypadOverworld (home/overworld.asm) simulates a held PAD_DOWN on ROUTE_17 whenever no d-pad/A/B input is held (and no trainer battle is starting); DoBikeSpeedup additionally suppresses the 2x bike speed on ROUTE_17 while UP/LEFT/RIGHT is held. """ tiles = {} path = os.path.join(pokered, "data/maps/force_bike_surf.asm") for lineno, line in read_asm(path): m = re.match(r"force_bike_surf\s+(\w+),\s*(\d+),\s*(\d+)$", line.strip()) if m: map_id = m.group(1) mode = "surf" if map_id.startswith("SEAFOAM") else "bike" tiles.setdefault(map_id, []).append( {"x": int(m.group(2)), "y": int(m.group(3)), "mode": mode}) overworld = "\n".join(l.strip() for _, l in read_asm(os.path.join(pokered, "home/overworld.asm"))) if not re.search(r"cp ROUTE_17.*?\n(.*\n){0,4}\s*ld a, PAD_DOWN", overworld): util.die("home/overworld.asm: Cycling Road forced PAD_DOWN not found") return {"tiles": tiles, "slopeMaps": ["ROUTE_17"]} PAD_TO_DIR = {"PAD_UP": "up", "PAD_DOWN": "down", "PAD_LEFT": "left", "PAD_RIGHT": "right"} def _parse_script_tables(path): """Collect labelled dbmapcoord lists and `db PAD_*, n` RLE lists. Duplicate labels (e.g. two `.Coords` locals) get a #2, #3... suffix. RLE lists are returned in source order; like the spinner tables they are decoded into wSimulatedJoypadStatesEnd and played back with a decrementing index, so they execute in REVERSE source order. """ coords = {} rle = {} label = None seen = {} for lineno, line in read_asm(path): s = line.strip() # local labels may omit the colon; bare instructions that happen to # match ("ret") just become labels no table refers to m = re.match(r"\.?(\w+):{0,2}$", s) if m: label = m.group(1) seen[label] = seen.get(label, 0) + 1 if seen[label] > 1: label = f"{label}#{seen[label]}" continue m = re.match(r"dbmapcoord\s+(\d+),\s*(\d+)$", s) if m and label: coords.setdefault(label, []).append( {"x": int(m.group(1)), "y": int(m.group(2))}) continue m = re.match(r"db\s+(PAD_\w+),\s*(\d+)$", s) if m and label: rle.setdefault(label, []).append( {"dir": PAD_TO_DIR[m.group(1)], "count": int(m.group(2))}) return coords, rle def parse_seafoam(pokered): """Seafoam Islands surf currents and boulder/hole wiring. scripts/SeafoamIslandsB3F.asm / SeafoamIslandsB4F.asm: * The current tiles are the SEAFOAM entries of ForcedBikeOrSurfMaps; stepping on one triggers the map's MOVE_OBJECT script, which (unless the plugging boulders' events are set) decodes an RLE movement list into simulated joypad presses -- executed in reverse source order, like the spinner tables. * B3F additionally sweeps the player from the surf entry at (15,8) toward the holes while the currents are live, and B4F force-exits the player upward at the pool's south edge (20..21,16..17). * Pushing a boulder into an upper floor's hole coords sets an EVENT_SEAFOAM*_BOULDER*_DOWN_HOLE flag, hides the pushed boulder object and shows the fallen one on the floor below (which is what plugs that floor's current); the holes double as dungeon warps. """ b3f_coords, b3f_rle = _parse_script_tables( os.path.join(pokered, "scripts/SeafoamIslandsB3F.asm")) b4f_coords, b4f_rle = _parse_script_tables( os.path.join(pokered, "scripts/SeafoamIslandsB4F.asm")) b2f_coords, _ = _parse_script_tables( os.path.join(pokered, "scripts/SeafoamIslandsB2F.asm")) oneF_coords, _ = _parse_script_tables( os.path.join(pokered, "scripts/SeafoamIslands1F.asm")) b1f_coords, _ = _parse_script_tables( os.path.join(pokered, "scripts/SeafoamIslandsB1F.asm")) def rev(label, rle_tables): moves = rle_tables.get(label) if not moves: util.die(f"seafoam: missing RLE list {label}") return list(reversed(moves)) def hole_wiring(script_path, holes, lands_at): """SetEvent / TOGGLE hide+show pairs, in source order.""" text = "\n".join(l.strip() for _, l in read_asm(script_path)) events = re.findall(r"SetEvent(?:ReuseHL|AfterBranchReuseHL)?\s+" r"(EVENT_SEAFOAM\d_BOULDER\d_DOWN_HOLE)", text) toggles = re.findall(r"ld a, (TOGGLE_SEAFOAM_ISLANDS_\w+)", text) if len(events) != 2 or len(toggles) != 4 or len(holes) != 2: util.die(f"seafoam: unexpected hole wiring in {script_path}") out = [] for i, hole in enumerate(holes): out.append({ "x": hole["x"], "y": hole["y"], "boulderEvent": events[i], "hideObject": toggles[2 * i], "showObject": toggles[2 * i + 1], "landsAt": lands_at[i], }) return out # fallen boulder object positions on the floor below def boulder_objects(objects_file): out = [] for lineno, line in read_asm(os.path.join(pokered, objects_file)): m = re.match(r"object_event\s+(\d+),\s*(\d+),\s*SPRITE_BOULDER", line.strip()) if m: out.append({"x": int(m.group(1)), "y": int(m.group(2))}) return out b3f_text = "\n".join(l.strip() for _, l in read_asm( os.path.join(pokered, "scripts/SeafoamIslandsB3F.asm"))) m = re.search(r"ld a, \[wYCoord\]\s+cp (\d+)\s+ret nz\s+" r"ld a, \[wXCoord\]\s+cp (\d+)", b3f_text) if not m: util.die("SeafoamIslandsB3F.asm: entry-current trigger coords not found") entry_y, entry_x = int(m.group(1)), int(m.group(2)) if "cp 18" not in b3f_text or "cp 19" not in b3f_text: util.die("SeafoamIslandsB3F.asm: current tile x checks changed") b3f_boulders = boulder_objects("data/maps/objects/SeafoamIslandsB3F.asm") b4f_boulders = boulder_objects("data/maps/objects/SeafoamIslandsB4F.asm") b1f_boulders = boulder_objects("data/maps/objects/SeafoamIslandsB1F.asm") b2f_boulders = boulder_objects("data/maps/objects/SeafoamIslandsB2F.asm") if len(b3f_boulders) != 6 or len(b4f_boulders) != 2: util.die("seafoam: unexpected boulder object counts") # the second .Coords local in B4F is the current-tile trigger list; # it must agree with the hardcoded current coords below if b4f_coords.get("Coords#2") != [{"x": 4, "y": 14}, {"x": 5, "y": 14}]: util.die("SeafoamIslandsB4F.asm: current trigger coords changed") seafoam = { "SEAFOAM_ISLANDS_B3F": { # both currents die once these two flags are set "currentsDisabledByEvents": ["EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE", "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE"], "currents": [ {"x": 18, "y": 7, "moves": rev("RLEList_StrongCurrentNearLeftBoulder", b3f_rle)}, {"x": 19, "y": 7, "moves": rev("RLEList_StrongCurrentNearRightBoulder", b3f_rle)}, ], # sweeps the player from the surf entry while currents are live "entryCurrent": { "x": entry_x, "y": entry_y, "moves": rev("RLEList_ForcedSurfingStrongCurrentNearSteps", b3f_rle), }, # pushing boulders into these B3F holes plugs the B4F current "holes": hole_wiring( os.path.join(pokered, "scripts/SeafoamIslandsB3F.asm"), b3f_coords.get("Seafoam4HolesCoords", []), b4f_boulders), "holeDestination": "SEAFOAM_ISLANDS_B4F", }, "SEAFOAM_ISLANDS_B4F": { "currentsDisabledByEvents": ["EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE", "EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE"], "currents": [ {"x": 4, "y": 14, "moves": rev("RLEList_StrongCurrentNearLeftBoulder", b4f_rle)}, {"x": 5, "y": 14, "moves": rev("RLEList_StrongCurrentNearRightBoulder", b4f_rle)}, ], # while the B3F boulders are NOT both down, standing here forces # the player up out of the water (2 up-presses on row 17, 1 on 16) "forcedExit": { "coords": b4f_coords.get("Coords", []), "activeUntilEvents": ["EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE", "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE"], }, }, } # the B3F current tiles are plugged by boulders pushed through the B2F # holes (scripts/SeafoamIslandsB2F.asm Seafoam3HolesCoords); the fallen # boulders are the last two B3F boulder objects, at (18,6)/(19,6) just # above the current tiles (data/maps/toggleable_objects.asm maps # TOGGLE_..._B3F_BOULDER_3/4 to SEAFOAMISLANDSB3F_BOULDER5/6) seafoam["SEAFOAM_ISLANDS_B3F"]["pluggedByHolesOn"] = { "map": "SEAFOAM_ISLANDS_B2F", "holes": hole_wiring( os.path.join(pokered, "scripts/SeafoamIslandsB2F.asm"), b2f_coords.get("Seafoam3HolesCoords", []), b3f_boulders[-2:]), } # pushing 1F's boulders into Seafoam1HolesCoords drops them to B1F, and # B1F's into Seafoam2HolesCoords drops them to B2F (scripts/ # SeafoamIslands1F.asm / SeafoamIslandsB1F.asm); this is the upper half # of the same cascade that pluggedByHolesOn wires for B2F->B3F. seafoam["SEAFOAM_ISLANDS_1F"] = { "holes": hole_wiring( os.path.join(pokered, "scripts/SeafoamIslands1F.asm"), oneF_coords.get("Seafoam1HolesCoords", []), b1f_boulders), "holeDestination": "SEAFOAM_ISLANDS_B1F", } seafoam["SEAFOAM_ISLANDS_B1F"] = { "holes": hole_wiring( os.path.join(pokered, "scripts/SeafoamIslandsB1F.asm"), b1f_coords.get("Seafoam2HolesCoords", []), b2f_boulders), "holeDestination": "SEAFOAM_ISLANDS_B2F", } return seafoam def parse_game_corner_poster(pokered): """scripts/GameCorner.asm: the poster switch that opens the hideout. Examining the poster (bg_event TEXT_GAMECORNER_POSTER) runs GameCornerPosterText, which sets EVENT_FOUND_ROCKET_HIDEOUT and replaces the tile block at block coords (8,2) -- the top-right corner of the room -- with the staircase block $43 (ReplaceTileBlock takes b=Y, c=X block coords). On map load, GameCornerSetRocketHideoutDoorTile writes the closed block $2a over the same block while the event is unset. """ text = "\n".join(l.strip() for _, l in read_asm( os.path.join(pokered, "scripts/GameCorner.asm"))) closed = re.search(r"CheckEvent EVENT_FOUND_ROCKET_HIDEOUT\s+ret nz\s+" r"ld a, (\$\w+)\s+ld \[wNewTileBlockID\], a\s+" r"lb bc, (\d+), (\d+)", text) opened = re.search(r"SetEvent EVENT_FOUND_ROCKET_HIDEOUT\s+" r"ld a, (\$\w+)\s+ld \[wNewTileBlockID\], a\s+" r"lb bc, (\d+), (\d+)", text) if not closed or not opened or closed.group(2, 3) != opened.group(2, 3): util.die("GameCorner.asm: poster block swap not found") poster = None for lineno, line in read_asm(os.path.join(pokered, "data/maps/objects/GameCorner.asm")): m = re.match(r"bg_event\s+(\d+),\s*(\d+),\s*(TEXT_GAMECORNER_POSTER)", line.strip()) if m: poster = {"x": int(m.group(1)), "y": int(m.group(2))} if poster is None: util.die("objects/GameCorner.asm: poster bg_event not found") return { "map": "GAME_CORNER", "x": int(opened.group(3)), # block coords (c = X) "y": int(opened.group(2)), # block coords (b = Y) "closedBlock": parse_number(closed.group(1)), "openBlock": parse_number(opened.group(1)), "event": "EVENT_FOUND_ROCKET_HIDEOUT", "posterText": "TEXT_GAMECORNER_POSTER", "poster": poster, # bg_event tile coords of the poster } def parse_badge_gates(pokered): """scripts/Route22Gate.asm and scripts/Route23.asm badge checks. Route 22 gate: standing on Route22GateScriptCoords triggers the guard, who checks BIT_BOULDERBADGE in wObtainedBadges. Route 23: Route23DefaultScript matches wYCoord against Route23GuardsYCoords; row i (top to bottom) is guarded by sprite i+1 and requires the badge at BadgeTextPointers[N-1-i] (EARTHBADGE at the northernmost row down to CASCADEBADGE at the southernmost). The y=35 row only applies at x < 14. Passing a guard sets its EVENT_PASSED__CHECK flag so it is skipped afterwards. """ r22_lines = read_asm(os.path.join(pokered, "scripts/Route22Gate.asm")) r22_text = "\n".join(l.strip() for _, l in r22_lines) if "bit BIT_BOULDERBADGE" not in r22_text: util.die("Route22Gate.asm: BOULDERBADGE check not found") r22_coords, _ = _parse_script_tables( os.path.join(pokered, "scripts/Route22Gate.asm")) coords = r22_coords.get("Route22GateScriptCoords", []) ys = [] badge_ptr_labels = [] badge_names = {} text_labels = [] label = None in_ys = in_ptrs = in_texts = False for lineno, line in read_asm(os.path.join(pokered, "scripts/Route23.asm")): s = line.strip() m = re.match(r"(\w+)::?\s*$", s) if m: label = m.group(1) in_ys = label == "Route23GuardsYCoords" in_ptrs = label == "BadgeTextPointers" continue if s == "def_text_pointers": in_texts = True continue if in_texts: m = re.match(r"dw_const\s+(\w+),\s*(\w+)$", s) if m: text_labels.append(m.group(1)) continue in_texts = False if in_ys: m = re.match(r"db\s+(\d+)$", s) if m: ys.append(int(m.group(1))) elif in_ptrs: m = re.match(r"dw\s+(\w+)$", s) if m: badge_ptr_labels.append(m.group(1)) m = re.match(r'db\s+"(\w+)@"$', s) if m and label: badge_names[label] = m.group(1) if len(ys) != 7 or len(badge_ptr_labels) != 7: util.die("Route23.asm: expected 7 guard rows and 7 badge pointers") guards = [] for i, y in enumerate(ys): badge = badge_names.get(badge_ptr_labels[len(ys) - 1 - i]) if not badge: util.die(f"Route23.asm: no badge name for row y={y}") guard = { "y": y, "badge": badge, "event": f"EVENT_PASSED_{badge}_CHECK", "sprite": i + 1, "text": text_labels[i] if i < len(text_labels) else None, } if i == 0: guard["maxX"] = 13 # y=35 row is skipped at wXCoord >= 14 guards.append(guard) events = "\n".join(l.strip() for _, l in read_asm( os.path.join(pokered, "constants/event_constants.asm"))) for g in guards: if g["event"] not in events: util.die(f"Route23: {g['event']} not in event_constants.asm") return { "ROUTE_22_GATE": { "coords": coords, "badge": "BOULDERBADGE", "text": "Route22GateGuardText", "failText": "Route22GateGuardNoBoulderbadgeText", "passText": "Route22GateGuardGoRightAheadText", }, "ROUTE_23": { "guards": guards, "failText": "Route23YouDontHaveTheBadgeYetText", "passText": "Route23OhThatIsTheBadgeText", }, } def parse_preset_names(pokered): """constants/player_constants.asm: preset name menus. The naming menus (engine/movie/oak_speech/oak_speech2.asm with data/player/names.asm / names_list.asm) offer NEW NAME plus these three presets each. Red/Blue gate the lists with IF DEF(_RED)/_BLUE; Yellow ships a single ungated set (YELLOW/ASH/JACK, BLUE/GARY/JOHN). read_asm resolves version conditionals via util.ASM_DEFINES. """ player, rival = [], [] path = os.path.join(pokered, "constants/player_constants.asm") for lineno, line in read_asm(path): s = line.strip() m = re.match(r'DEF\s+PLAYERNAME\d\s+EQUS\s+"(\w+)"', s) if m: player.append(m.group(1)) m = re.match(r'DEF\s+RIVALNAME\d\s+EQUS\s+"(\w+)"', s) if m: rival.append(m.group(1)) return {"player": player, "rival": rival, "customOption": "NEW NAME"} def parse_dark_maps(pokered): """Rock Tunnel darkness (home/overworld.asm). Warping into ROCK_TUNNEL_1F sets wMapPalOffset = 6, blacking the screen out until Flash is used. The offset is only cleared when leaving through a LAST_MAP warp back outside (or via Flash), so it persists across the indoor warps into ROCK_TUNNEL_B1F -- both floors are dark. Flash (engine/menus/start_sub_menus.asm .flash) needs BOULDERBADGE and simply zeroes wMapPalOffset. """ overworld = "\n".join(l.strip() for _, l in read_asm(os.path.join(pokered, "home/overworld.asm"))) if not re.search(r"cp ROCK_TUNNEL_1F\s+jr nz, \.notRockTunnel\s+" r"ld a, \$06\s+ld \[wMapPalOffset\], a", overworld): util.die("home/overworld.asm: Rock Tunnel darkness code changed") return { "maps": ["ROCK_TUNNEL_1F", "ROCK_TUNNEL_B1F"], "entryMap": "ROCK_TUNNEL_1F", # the only map that sets the offset "palOffset": 6, "flashBadge": "BOULDERBADGE", } def parse_warp_carpets(pokered): """data/tilesets/warp_carpet_tile_ids.asm + the ExtraWarpCheck routing. A warp fires without stepping onto a door/warp tile when the player stands on the warp square and ExtraWarpCheck (home/overworld.asm) passes -- either on a collision (CheckWarpsCollision) or on arrival with the d-pad held (CheckWarpsNoCollision). The check itself is: * "function 2" (IsWarpTileInFrontOfPlayer): the tile in front of the player is in the facing direction's warp-carpet list -- used on the OVERWORLD/SHIP/SHIP_PORT/PLATEAU tilesets plus the four map exceptions in function2Maps, with SS_ANNE_BOW checking tile $15 instead of the lists; * "function 1" (IsPlayerFacingEdgeOfMap) everywhere else (and on SS_ANNE_3F): the player faces the edge of the map. """ dir_labels = {"FacingDownWarpTiles": "down", "FacingUpWarpTiles": "up", "FacingLeftWarpTiles": "left", "FacingRightWarpTiles": "right"} carpets = {} label = None path = os.path.join(pokered, "data/tilesets/warp_carpet_tile_ids.asm") for lineno, line in read_asm(path): s = line.strip() m = re.match(r"\.(\w+):$", s) if m: label = dir_labels.get(m.group(1)) continue m = re.match(r"warp_carpet_tiles\s+(.+)$", s) if m and label: carpets[label] = [parse_number(a) for a in split_args(m.group(1))] label = None if sorted(carpets) != ["down", "left", "right", "up"]: util.die("warp_carpet_tile_ids.asm: missing facing direction lists") overworld = "\n".join(l.strip() for _, l in read_asm(os.path.join(pokered, "home/overworld.asm"))) m = re.search(r"ExtraWarpCheck::\n(.*?)\.doBankswitch", overworld, re.S) if not m: util.die("home/overworld.asm: ExtraWarpCheck not found") body = m.group(1) map_part, tileset_marker, tileset_part = \ body.partition("ld a, [wCurMapTileset]") function2_maps = re.findall(r"cp (\w+)\njr z, \.useFunction2", map_part) edge_maps = re.findall(r"cp (\w+)\njr z, \.useFunction1", map_part) # `and a` tests for tileset 0 = OVERWORLD function2_tilesets = ["OVERWORLD"] + re.findall( r"cp (\w+)\njr z, \.useFunction2", tileset_part) if not tileset_marker or "and a\njr z, .useFunction2" not in tileset_part \ or function2_maps != ["ROCKET_HIDEOUT_B1F", "ROCKET_HIDEOUT_B2F", "ROCKET_HIDEOUT_B4F", "ROCK_TUNNEL_1F"] \ or edge_maps != ["SS_ANNE_3F"] \ or function2_tilesets != ["OVERWORLD", "SHIP", "SHIP_PORT", "PLATEAU"]: util.die("home/overworld.asm: ExtraWarpCheck routing changed") player_state = "\n".join(l.strip() for _, l in read_asm( os.path.join(pokered, "engine/overworld/player_state.asm"))) m = re.search(r"IsSSAnneBowWarpTileInFrontOfPlayer:\n" r"ld a, \[wTileInFrontOfPlayer\]\ncp (\$\w+)", player_state) if not m: util.die("player_state.asm: SS Anne bow warp tile check not found") return { "tiles": carpets, # facing dir -> tile-in-front ids "function2Maps": function2_maps, "edgeMaps": edge_maps, # tileset would say carpet; map says edge "function2Tilesets": function2_tilesets, "ssAnneBow": {"map": "SS_ANNE_BOW", "tile": parse_number(m.group(1))}, } def parse_dungeon_transition_maps(pokered): """data/maps/dungeon_maps.asm: the battle-transition dungeon lists. GetBattleTransitionID_IsDungeonMap checks wCurMap against the singles in DungeonMaps1 and the inclusive id ranges in DungeonMaps2 (the lists famously miss several dungeons -- kept as-is on purpose). """ singles, ranges = [], [] section = None path = os.path.join(pokered, "data/maps/dungeon_maps.asm") for lineno, line in read_asm(path): s = line.strip() if s == "DungeonMaps1:": section = singles continue if s == "DungeonMaps2:": section = ranges continue m = re.match(r"db\s+(\w+),\s*(\w+)$", s) if m and section is ranges: ranges.append({"first": m.group(1), "last": m.group(2)}) continue m = re.match(r"db\s+(\w+)$", s) if m and m.group(1) != "-1" and section is singles: singles.append(m.group(1)) return {"maps": singles, "ranges": ranges} def parse_bike_riding(pokered): """data/tilesets/bike_riding_tilesets.asm + IsBikeRidingAllowed. The bike may be ridden on maps whose tileset is in the list, plus the ROUTE_23 / INDIGO_PLATEAU map exceptions (home/overworld.asm). """ tilesets = [] path = os.path.join(pokered, "data/tilesets/bike_riding_tilesets.asm") for lineno, line in read_asm(path): m = re.match(r"db\s+(\w+)$", line.strip()) if m and m.group(1) != "-1": tilesets.append(m.group(1)) overworld = "\n".join(l.strip() for _, l in read_asm(os.path.join(pokered, "home/overworld.asm"))) body = overworld[overworld.find("IsBikeRidingAllowed::"):] body = body[:body.find("ld a, [wCurMapTileset]")] # map checks come first maps = re.findall(r"cp (\w+)\njr z, \.allowed", body) if maps != ["ROUTE_23", "INDIGO_PLATEAU"]: util.die("home/overworld.asm: IsBikeRidingAllowed map exceptions changed") return {"tilesets": tilesets, "maps": maps} def parse_indoor_encounters(pokered): """engine/battle/wild_encounters.asm indoor rule. On maps with id >= FIRST_INDOOR_MAP whose tileset is not FOREST, every walkable tile rolls grass-table encounters (caves, towers, the Mansion). """ wild = "\n".join(l.strip() for _, l in read_asm( os.path.join(pokered, "engine/battle/wild_encounters.asm"))) if not re.search(r"cp FIRST_INDOOR_MAP.*\njr c, \.CantEncounter2\n" r"ld a, \[wCurMapTileset\]\ncp FOREST", wild): util.die("wild_encounters.asm: indoor encounter rule changed") _, first_indoor, _ = parse_map_constants(pokered) return {"firstIndoorMap": first_indoor, "excludedTileset": "FOREST"} # The three maps with spinner arrow tiles keep their movement tables in # their map scripts (map_coord_movement x, y -> RLE list; each list is # read backwards from the terminator -- see scripts/RocketHideoutB2F.asm). SPINNER_SCRIPTS = { "VIRIDIAN_GYM": "ViridianGym.asm", "ROCKET_HIDEOUT_B2F": "RocketHideoutB2F.asm", "ROCKET_HIDEOUT_B3F": "RocketHideoutB3F.asm", } PAD_DIRS = {"PAD_UP": "up", "PAD_DOWN": "down", "PAD_LEFT": "left", "PAD_RIGHT": "right"} def parse_spinners(pokered): spinners = {} for map_id, fname in sorted(SPINNER_SCRIPTS.items()): path = os.path.join(pokered, "scripts", fname) table = [] # (x, y, label) lists = {} # label -> [(dir, count)] in source order current_list = None in_table = False for lineno, line in read_asm(path): s = line.strip() m = re.match(r"map_coord_movement\s+(\d+),\s*(\d+),\s*(\w+)", s) if m: in_table = True table.append((int(m.group(1)), int(m.group(2)), m.group(3))) continue m = re.match(r"(\w*ArrowMovement\w*):", s) if m: current_list = [] lists[m.group(1)] = current_list continue m = re.match(r"db\s+(PAD_\w+),\s*(\d+)", s) if m and current_list is not None: current_list.append((PAD_DIRS[m.group(1)], int(m.group(2)))) continue if s.startswith("db -1") and current_list is not None: current_list = None entries = [] for x, y, label in table: moves = lists.get(label) if moves is None: util.die(f"spinners: {fname}: missing movement list {label}") # lists execute from the terminator backwards entries.append({"x": x, "y": y, "moves": [{"dir": d, "count": c} for d, c in reversed(moves)]}) if not entries: util.die(f"spinners: {fname}: no arrow tile table found") spinners[map_id] = entries return spinners def parse_map_constants(pokered): """constants/map_constants.asm: ordered map ids + indoor group markers. Returns (maps, first_indoor, groups) where groups is an ordered list of (group_name, boundary): INDOORGROUP_ equals the map id AFTER the group's last map (end_indoor_group defines it as const_value). """ maps = [] groups = [] first_indoor = None path = os.path.join(pokered, "constants/map_constants.asm") for lineno, line in read_asm(path): s = line.strip() m = re.match(r"map_const\s+(\w+),", s) if m: maps.append(m.group(1)) continue m = re.match(r"end_indoor_group\s+(\w+)$", s) if m: groups.append((m.group(1), len(maps))) continue if re.match(r"DEF\s+FIRST_INDOOR_MAP\b", s): first_indoor = len(maps) if not maps or first_indoor is None or not groups: util.die("map_constants.asm: could not parse map ids/indoor groups") return maps, first_indoor, groups def parse_town_map(pokered): """data/maps/town_map_entries.asm (+ names.asm, town_map_order.asm). Every map gets a town-map position and display name: * outdoor maps (id < FIRST_INDOOR_MAP) index ExternalMapEntries directly; `outdoor_map x, y, Name` stores `dn y, x` + name pointer. * indoor maps are looked up in InternalMapEntries by LoadTownMapEntry (engine/items/town_map.asm): the first entry whose INDOORGROUP_* boundary exceeds the map id wins, so one entry covers a contiguous id range (`indoor_map GROUP, x, y, Name`). Coordinates are a 16x16 nybble grid; the cursor/player marker is drawn at pixel (x*8 + 24, y*8 + 24) in OAM coords (TownMapCoordsToOAMCoords), i.e. an 8px grid over the 160x144 map screen. Routes only get a single x,y point in this data (no spans). TownMapOrder is the SELECT-cursor order when scrolling through locations. """ maps, first_indoor, groups = parse_map_constants(pokered) names = {} for lineno, line in read_asm(os.path.join(pokered, "data/maps/names.asm")): m = re.match(r'(\w+):\s*db\s+"([^"]*)"', line.strip()) if m: names[m.group(1)] = text.decode_string(m.group(2), lineno, "data/maps/names.asm") external = [] # (x, y, name label), index = map id internal = [] # (group, x, y, name label) path = os.path.join(pokered, "data/maps/town_map_entries.asm") for lineno, line in read_asm(path): s = line.strip() m = re.match(r"outdoor_map\s+(\d+),\s*(\d+),\s*(\w+)$", s) if m: external.append((int(m.group(1)), int(m.group(2)), m.group(3))) continue m = re.match(r"indoor_map\s+(\w+),\s*(\d+),\s*(\d+),\s*(\w+)$", s) if m: internal.append((m.group(1), int(m.group(2)), int(m.group(3)), m.group(4))) if len(external) != first_indoor: util.die(f"town map: {len(external)} outdoor entries != FIRST_INDOOR_MAP " f"({first_indoor})") if [g for g, _ in groups] != [e[0] for e in internal]: util.die("town map: indoor_map groups do not match map_constants.asm") def entry(x, y, label): if label not in names or not (0 <= x <= 15 and 0 <= y <= 15): util.die(f"town map: bad entry {x},{y},{label}") return {"x": x, "y": y, "name": names[label]} locations = {} for map_id, (x, y, label) in zip(maps[:first_indoor], external): if not map_id.startswith("UNUSED_MAP"): locations[map_id] = entry(x, y, label) prev = first_indoor for (group, x, y, label), (_, boundary) in zip(internal, groups): if boundary <= prev or boundary > len(maps): util.die(f"town map: group {group} boundary {boundary} out of order") for map_id in maps[prev:boundary]: if not map_id.startswith("UNUSED_MAP"): locations[map_id] = entry(x, y, label) prev = boundary if prev != len(maps): util.die("town map: indoor groups do not cover all maps") cursor_order = [] started = False for lineno, line in read_asm(os.path.join(pokered, "data/maps/town_map_order.asm")): s = line.strip() if s == "TownMapOrder:": started = True continue m = re.match(r"db\s+(\w+)$", s) if started and m: cursor_order.append(m.group(1)) for map_id in cursor_order: if map_id not in locations: util.die(f"town map: cursor order map {map_id} has no entry") return { "locations": locations, # map id -> {x, y, name} (16x16 grid) "cursorOrder": cursor_order, "gridPixelSize": 8, # marker pixel = coord*8 + 24 (OAM coords) } def parse_credits(pokered): """The end-credits roll (engine/movie/credits.asm Credits). data/credits/credits_order.asm is a byte stream: CRED_* string ids accumulate lines on the current screen, and CRED_TEXT / CRED_TEXT_FADE / CRED_TEXT_MON / CRED_TEXT_FADE_MON terminate it (FADE = palette fade-in, MON = scroll in the next CreditsMons entry afterwards). CRED_COPYRIGHT draws the copyright logo (the `title.copyright` asset) on the current screen; CRED_THE_END ends the roll and shows the the_end graphic. Each string in credits_text.asm starts with a signed db: the x offset from column 9 where the line is placed (lines are printed at rows 6, 8, 10, ... of the screen). """ cred_names = util.parse_const_block( os.path.join(pokered, "constants/credits_constants.asm"), stop_at="NUM_CRED_STRINGS") # CreditsTextPointers: CRED_* value -> string label. # Version-gated CredVersion / CreditsText_Version bodies (Red/Blue IF # DEF) are resolved by read_asm via util.ASM_DEFINES; Yellow has no # gates and a single "YELLOW VERSION" string. pointers = [] strings = {} label = None path = os.path.join(pokered, "data/credits/credits_text.asm") for lineno, line in read_asm(path): s = line.strip() m = re.match(r"dw\s+(\w+)$", s) if m: pointers.append(m.group(1)) continue m = re.match(r"(\w+):$", s) if m and m.group(1) != "CreditsTextPointers": label = m.group(1) continue # Optional trailing @ terminator (Yellow omits it on some lines). m = re.match(r'db\s+(-\d+),\s*"([^"]*)"$', s) if m and label: strings[label] = { "column": 9 + int(m.group(1)), # hlcoord 9,6 + signed offset "text": text.decode_string(m.group(2), lineno, "data/credits/credits_text.asm"), } if len(pointers) != len(cred_names) or any(n is None for n in cred_names): util.die("credits: text pointer table does not match CRED_* constants") mons = [] for lineno, line in read_asm(os.path.join(pokered, "data/credits/credits_mons.asm")): m = re.match(r"db\s+(\w+)$", line.strip()) if m: mons.append(m.group(1)) cred_index = {name: i for i, name in enumerate(cred_names)} commands = {"CRED_TEXT": (False, False), "CRED_TEXT_FADE": (True, False), "CRED_TEXT_MON": (False, True), "CRED_TEXT_FADE_MON": (True, True)} screens = [] current = {"lines": []} the_end_seen = False mon_count = 0 path = os.path.join(pokered, "data/credits/credits_order.asm") for lineno, line in read_asm(path): m = re.match(r"db\s+(.+)$", line.strip()) if not m: continue for tok in split_args(m.group(1)): if the_end_seen: util.die("credits_order.asm: data after CRED_THE_END") if tok in commands: fade, mon = commands[tok] current["fade"] = fade if mon: if mon_count >= len(mons): util.die("credits_order.asm: more MON screens than CreditsMons") current["mon"] = mons[mon_count] mon_count += 1 screens.append(current) current = {"lines": []} elif tok == "CRED_COPYRIGHT": current["copyright"] = True elif tok == "CRED_THE_END": the_end_seen = True elif tok in cred_index: label = pointers[cred_index[tok]] if label not in strings: util.die(f"credits_order.asm:{lineno}: no string for {tok}") current["lines"].append(dict(strings[label])) else: util.die(f"credits_order.asm:{lineno}: unknown token {tok}") if not the_end_seen or current["lines"]: util.die("credits_order.asm: missing CRED_THE_END terminator") if mon_count != len(mons): util.die(f"credits: {len(mons)} CreditsMons but {mon_count} MON screens") return { "screens": screens, # {lines = {{text, column}...}, fade, mon?, copyright?} "mons": mons, # CreditsMons, consumed in order by MON screens } def parse_old_man_battle(pokered): """scripts/ViridianCity.asm: the old man's catch-demo wild battle. ViridianCityOldManStartCatchTrainingScript sets wBattleType = BATTLE_TYPE_OLD_MAN, wCurEnemyLevel = 5 and wCurOpponent = WEEDLE; engine/battle/core.asm special-cases that battle type (the player's name is temporarily swapped for OLD MAN, the source of the MissingNo. glitch). The end script shows the "you need to weaken the target" text afterwards. """ script = "\n".join(l.strip() for _, l in read_asm( os.path.join(pokered, "scripts/ViridianCity.asm"))) m = re.search(r"ld a, BATTLE_TYPE_OLD_MAN\s+ld \[wBattleType\], a\s+" r"ld a, (\d+)\s+ld \[wCurEnemyLevel\], a\s+" r"ld a, (\w+)\s+ld \[wCurOpponent\], a", script) if not m: util.die("ViridianCity.asm: old man battle setup not found") core = "\n".join(l.strip() for _, l in read_asm( os.path.join(pokered, "engine/battle/core.asm"))) if "ASSERT BATTLE_TYPE_OLD_MAN == 1" not in core: util.die("core.asm: old man battle type handling changed") texts = {} for text_id, key in (("TEXT_VIRIDIANCITY_OLD_MAN", "text"), ("TEXT_VIRIDIANCITY_OLD_MAN_YOU_NEED_TO_WEAKEN_THE_TARGET", "afterText")): tm = re.search(rf"dw_const\s+(\w+),\s+{text_id}$", script, re.M) if not tm: util.die(f"ViridianCity.asm: {text_id} not found") texts[key] = tm.group(1) return { "map": "VIRIDIAN_CITY", "species": m.group(2), "level": int(m.group(1)), "battleType": "BATTLE_TYPE_OLD_MAN", "text": texts["text"], # ViridianCityOldManText "afterText": texts["afterText"], # ...YouNeedToWeakenTheTargetText } def parse_coin_purchases(pokered): """scripts/GameCorner.asm GameCornerClerk1Text: coins for money. The only purchase the script implements is 50 coins for ¥1000 (BCD: hMoney = 00 10 00, hCoins = 00 50); text/GameCorner.asm's dialogue ("It's ¥1000 for 50 coins") matches. There is NO 500-coin/¥10000 option in Red. Requires the COIN_CASE and room for the coins (Has9990Coins must carry). """ script = "\n".join(l.strip() for _, l in read_asm( os.path.join(pokered, "scripts/GameCorner.asm"))) buys = re.findall( r"xor a\s+ldh \[hMoney\], a\s+ldh \[hMoney \+ 2\], a\s+" r"ld a, \$(\d+)\s+ldh \[hMoney \+ 1\], a\s+" r"ld hl, hMoney \+ 2\s+ld de, wPlayerMoney \+ 2\s+ld c, \$3\s+" r"predef SubBCDPredef\s+" r"xor a\s+ldh \[hUnusedCoinsByte\], a\s+ldh \[hCoins\], a\s+" r"ld a, \$(\d+)\s+ldh \[hCoins \+ 1\], a", script) if len(buys) != 1: util.die(f"GameCorner.asm: expected exactly 1 coin purchase, got {len(buys)}") money_mid, coins_low = buys[0] # BCD: hMoney = 00 00, hCoins = 00 price = int(f"00{money_mid}00") coins = int(f"00{coins_low}") dialogue = "\n".join(l.strip() for _, l in read_asm( os.path.join(pokered, "text/GameCorner.asm"))) if f"¥{price} for {coins}" not in dialogue: util.die("GameCorner text/script coin purchase mismatch") return [{"coins": coins, "price": price}] def parse_pc_item_cap(pokered): """constants/menu_constants.asm PC_ITEM_CAPACITY (wNumBoxItems size). engine/items/inventory.asm AddItemToInventory uses it as the cap when hl = wNumBoxItems (`ld d, PC_ITEM_CAPACITY`). """ cap = None path = os.path.join(pokered, "constants/menu_constants.asm") for lineno, line in read_asm(path): m = re.match(r"DEF\s+PC_ITEM_CAPACITY\s+EQU\s+(\d+)$", line.strip()) if m: cap = int(m.group(1)) if cap is None: util.die("menu_constants.asm: PC_ITEM_CAPACITY not found") inventory = "\n".join(l.strip() for _, l in read_asm( os.path.join(pokered, "engine/items/inventory.asm"))) if "ld d, PC_ITEM_CAPACITY" not in inventory: util.die("inventory.asm: PC_ITEM_CAPACITY use not found") return cap def extract(pokered, out_dir): ledges = [] for lineno, line in read_asm(os.path.join(pokered, "data/tilesets/ledge_tiles.asm")): m = re.match(r"db\s+(SPRITE_FACING_\w+),\s*(\$\w+),\s*(\$\w+),\s*PAD_(\w+)", line.strip()) if m: ledges.append({ "facing": DIRS[m.group(1)], "standingTile": parse_number(m.group(2)), "ledgeTile": parse_number(m.group(3)), "input": m.group(4).lower(), }) cut_trees = [] for lineno, line in read_asm(os.path.join(pokered, "data/tilesets/cut_tree_blocks.asm")): m = re.match(r"db\s+(\$\w+),\s*(\$\w+)$", line.strip()) if m: cut_trees.append({"before": parse_number(m.group(1)), "after": parse_number(m.group(2))}) water = [] started = False for lineno, line in read_asm(os.path.join(pokered, "data/tilesets/water_tilesets.asm")): s = line.strip() if s.startswith("WaterTilesets"): started = True continue m = re.match(r"db\s+(\w+)$", s) if started and m and m.group(1) != "-1": water.append(m.group(1)) # tile-pair collisions (data/tilesets/pair_collision_tile_ids.asm): # you may not cross between tile1 and tile2 in the given tileset -- # elevation edges in caves and the forest. Land pairs apply while # walking; water pairs while surfing. tile_pairs = {"land": [], "water": []} group = None for lineno, line in read_asm( os.path.join(pokered, "data/tilesets/pair_collision_tile_ids.asm")): s = line.strip() if s.startswith("TilePairCollisionsLand"): group = "land" continue if s.startswith("TilePairCollisionsWater"): group = "water" continue m = re.match(r"db\s+(\w+),\s*(\$\w+),\s*(\$\w+)", s) if group and m: tile_pairs[group].append({ "tileset": m.group(1), "a": parse_number(m.group(2)), "b": parse_number(m.group(3)), }) trades = parse_trades(pokered) fly_warps, fly_order = parse_fly_warps(pokered) super_rod = parse_super_rod(pokered) hidden_items, hidden_coins, slot_machines, extras = parse_hidden_events(pokered) slot_wheels = parse_slot_wheels(pokered) spinners = parse_spinners(pokered) # resolve bench guy texts (map -> text label + facing the engine checks) bench_texts = parse_bench_guy_texts(pokered) for map_id, guys in extras["benchGuys"].items(): entry = bench_texts.get(map_id) for guy in guys: if entry: guy["text"] = entry["text"] guy["textFacing"] = entry["facing"] hidden_extras = { "pcTiles": extras["pcTiles"], "benchGuys": extras["benchGuys"], "gymStatues": extras["gymStatues"], "printTrash": extras["printTrash"], "trashCans": parse_trash_can_puzzle(pokered, extras["trashCans"]), } card_key_doors = parse_card_key_doors(pokered) forced_movement = parse_forced_movement(pokered) seafoam = parse_seafoam(pokered) game_corner_poster = parse_game_corner_poster(pokered) badge_gates = parse_badge_gates(pokered) preset_names = parse_preset_names(pokered) dark_maps = parse_dark_maps(pokered) warp_carpets = parse_warp_carpets(pokered) dungeon_transition_maps = parse_dungeon_transition_maps(pokered) bike_riding = parse_bike_riding(pokered) indoor_encounters = parse_indoor_encounters(pokered) town_map = parse_town_map(pokered) credits = parse_credits(pokered) old_man_battle = parse_old_man_battle(pokered) coin_purchases = parse_coin_purchases(pokered) pc_item_cap = parse_pc_item_cap(pokered) # title screen assets live next to the other generated assets; build_data # passes only the data dir, so derive assets/generated from it assets_dir = os.path.normpath( os.path.join(out_dir, os.pardir, os.pardir, "assets", "generated")) title = gfx.extract_title(pokered, assets_dir) intro = gfx.extract_intro(pokered, assets_dir) slot_symbols = gfx.extract_slots(pokered, assets_dir) emotion_bubbles = gfx.extract_emotes(pokered, assets_dir) oak_speech = gfx.extract_oak_speech(pokered, assets_dir) overworld_fx = gfx.extract_overworld_fx(pokered, assets_dir) credits["theEnd"] = gfx.extract_the_end(pokered, assets_dir) battle_hud = gfx.extract_battle_hud(pokered, assets_dir) town_map["background"] = gfx.extract_town_map_bg(pokered, assets_dir) trade_art = gfx.extract_trade(pokered, assets_dir) if not ledges or not cut_trees or "OVERWORLD" not in water or len(trades) < 8 \ or "PALLET_TOWN" not in fly_warps: util.die("field extraction sanity check failed") if "VIRIDIAN_FOREST" not in hidden_items or len(slot_wheels) != 3 \ or "VIRIDIAN_GYM" not in spinners: util.die("hidden events / slots / spinner extraction sanity check failed") if "SILPH_CO_2F" not in card_key_doors["doors"] \ or len(card_key_doors["doors"]["SILPH_CO_11F"]) != 2: util.die("card key door extraction sanity check failed") if len(hidden_extras["trashCans"]["cans"]) != 15 \ or len(hidden_extras["pcTiles"]) < 10 \ or "VIRIDIAN_GYM" not in hidden_extras["gymStatues"] \ or hidden_extras["benchGuys"].get("VIRIDIAN_POKECENTER", [{}])[0].get("text") is None \ or len(hidden_extras["printTrash"].get("SS_ANNE_KITCHEN", [])) != 2 \ or len(hidden_extras["printTrash"].get("VERMILION_GYM", [])) != 1: util.die("hidden extras extraction sanity check failed") if sorted(forced_movement["tiles"]) != ["ROUTE_16", "ROUTE_18", "SEAFOAM_ISLANDS_B3F", "SEAFOAM_ISLANDS_B4F"] \ or forced_movement["slopeMaps"] != ["ROUTE_17"]: util.die("forced movement extraction sanity check failed") for map_id in ("SEAFOAM_ISLANDS_B3F", "SEAFOAM_ISLANDS_B4F"): if len(seafoam[map_id]["currents"]) != 2 \ or not all(c["moves"] for c in seafoam[map_id]["currents"]): util.die("seafoam current extraction sanity check failed") if len(seafoam["SEAFOAM_ISLANDS_B3F"]["holes"]) != 2 \ or len(seafoam["SEAFOAM_ISLANDS_B4F"]["forcedExit"]["coords"]) != 4: util.die("seafoam hole/exit extraction sanity check failed") if len(seafoam["SEAFOAM_ISLANDS_1F"]["holes"]) != 2 \ or len(seafoam["SEAFOAM_ISLANDS_B1F"]["holes"]) != 2: util.die("seafoam 1F/B1F hole extraction sanity check failed") if game_corner_poster["closedBlock"] == game_corner_poster["openBlock"] \ or game_corner_poster["closedBlock"] != 0x2A \ or game_corner_poster["openBlock"] != 0x43: util.die("Game Corner poster extraction sanity check failed") if len(badge_gates["ROUTE_23"]["guards"]) != 7 \ or badge_gates["ROUTE_23"]["guards"][0]["badge"] != "EARTHBADGE" \ or badge_gates["ROUTE_23"]["guards"][-1]["badge"] != "CASCADEBADGE" \ or len(badge_gates["ROUTE_22_GATE"]["coords"]) != 2: util.die("badge gate extraction sanity check failed") if len(preset_names["player"]) != 3 or len(preset_names["rival"]) != 3: util.die("preset name extraction sanity check failed") # Red expects RED/ASH/JACK + BLUE/GARY/JOHN. Yellow ships YELLOW/... with # no IF DEF gates; Blue swaps player/rival. Only enforce the Red pair when # building Red (ASM_DEFINES has _RED) or when RED already appears. if "_RED" in util.ASM_DEFINES or "RED" in preset_names["player"]: if "YELLOW" in preset_names["player"]: pass # pokeyellow ungated presets; Red name check does not apply elif "RED" not in preset_names["player"] \ or "BLUE" not in preset_names["rival"]: util.die("preset name extraction sanity check failed") elif "YELLOW" in preset_names["player"]: if "BLUE" not in preset_names["rival"]: util.die("preset name extraction sanity check failed") if "ROCK_TUNNEL_1F" not in dark_maps["maps"]: util.die("dark map extraction sanity check failed") if warp_carpets["tiles"]["down"] != [0x01, 0x12, 0x17, 0x3D, 0x04, 0x18, 0x33] \ or warp_carpets["tiles"]["up"] != [0x01, 0x5C] \ or warp_carpets["tiles"]["left"] != [0x1A, 0x4B] \ or warp_carpets["tiles"]["right"] != [0x0F, 0x4E] \ or warp_carpets["ssAnneBow"]["tile"] != 0x15: util.die("warp carpet extraction sanity check failed") if dungeon_transition_maps["maps"] != ["VIRIDIAN_FOREST", "ROCK_TUNNEL_1F", "SEAFOAM_ISLANDS_1F", "ROCK_TUNNEL_B1F"] \ or len(dungeon_transition_maps["ranges"]) != 4 \ or dungeon_transition_maps["ranges"][0] != {"first": "MT_MOON_1F", "last": "MT_MOON_B2F"}: util.die("dungeon transition map extraction sanity check failed") if bike_riding["tilesets"] != ["OVERWORLD", "FOREST", "UNDERGROUND", "SHIP_PORT", "CAVERN"]: util.die("bike riding tileset extraction sanity check failed") if indoor_encounters["firstIndoorMap"] != 0x25: util.die("indoor encounter boundary sanity check failed") if len(title) not in (5, 6) or any(not v["width"] for v in title.values()) \ or (title["gamefreakInc"]["width"], title["gamefreakInc"]["height"]) != (72, 8) \ or ("nine" in title and (title["nine"]["width"], title["nine"]["height"]) != (8, 8)): util.die("title asset extraction sanity check failed") if any((intro["gengar"][f]["width"], intro["gengar"][f]["height"]) != (56, 56) for f in ("frame1", "frame2", "frame3")) \ or any((intro["nidorino"][f]["width"], intro["nidorino"][f]["height"]) != (48, 48) for f in ("frame1", "frame2", "frame3")) \ or (intro["fallingStar"]["width"], intro["bigStar"]["width"], intro["gamefreakText"]["width"]) != (8, 16, 80): util.die("intro asset extraction sanity check failed") if town_map["locations"].get("PALLET_TOWN") != {"x": 2, "y": 11, "name": "PALLET TOWN"} \ or town_map["locations"].get("CERULEAN_CAVE_1F", {}).get("name") != "CERULEAN CAVE" \ or len(town_map["cursorOrder"]) != 47 \ or town_map["cursorOrder"][0] != "PALLET_TOWN": util.die("town map extraction sanity check failed") if len(credits["screens"]) != 35 or len(credits["mons"]) != 15 \ or credits["screens"][0]["lines"][1]["text"] != "RED VERSION STAFF" \ or credits["screens"][1]["lines"] != [{"column": 6, "text": "DIRECTOR"}, {"column": 3, "text": "SATOSHI TAJIRI"}] \ or not credits["screens"][-1].get("copyright") \ or credits["mons"][0] != "VENUSAUR": util.die("credits extraction sanity check failed") wheel_symbols = {sym for wheel in slot_wheels for sym in wheel} if wheel_symbols != set(slot_symbols["symbols"]) \ or slot_symbols["symbols"]["7"]["tiles"] != 0x0200: util.die("slot symbol extraction sanity check failed") if [b["name"] for b in emotion_bubbles["bubbles"]] != \ ["EXCLAMATION_BUBBLE", "QUESTION_BUBBLE", "SMILE_BUBBLE"]: util.die("emotion bubble extraction sanity check failed") if old_man_battle["species"] != "WEEDLE" or old_man_battle["level"] != 5: util.die("old man battle extraction sanity check failed") if coin_purchases != [{"coins": 50, "price": 1000}]: util.die("coin purchase extraction sanity check failed") if pc_item_cap != 50: util.die("PC item capacity sanity check failed") data = {"ledges": ledges, "cutTreeSwaps": cut_trees, "waterTilesets": water, "tilePairs": tile_pairs, "trades": trades, "flyWarps": fly_warps, "flyOrder": fly_order, "superRod": super_rod, "hiddenItems": hidden_items, "hiddenCoins": hidden_coins, "slotMachines": slot_machines, "slotWheels": slot_wheels, "spinners": spinners, "cardKeyDoors": card_key_doors, "hiddenExtras": hidden_extras, "forcedMovement": forced_movement, "seafoam": seafoam, "gameCornerPoster": game_corner_poster, "badgeGates": badge_gates, "presetNames": preset_names, "darkMaps": dark_maps, "warpCarpets": warp_carpets, "dungeonTransitionMaps": dungeon_transition_maps, "bikeRiding": bike_riding, "indoorEncounters": indoor_encounters, "title": title, "intro": intro, "battleHud": battle_hud, "townMap": town_map, "credits": credits, "slotSymbols": slot_symbols, "tradeArt": trade_art, "emotionBubbles": emotion_bubbles, "oakSpeech": oak_speech, "overworldFx": overworld_fx, "oldManBattle": old_man_battle, "coinPurchases": coin_purchases, "pcItemCap": pc_item_cap, "source": "data/tilesets/{ledge_tiles,cut_tree_blocks,water_tilesets}.asm,\n" "data/events/{trades,hidden_events,slot_machine_wheels,\n" "bench_guys,card_key_coords,card_key_maps}.asm,\n" "data/maps/{special_warps,force_bike_surf}.asm,\n" "engine/events/card_key.asm,\n" "engine/events/hidden_events/vermilion_gym_trash.asm,\n" "scripts/{SeafoamIslandsB2F,SeafoamIslandsB3F,SeafoamIslandsB4F,\n" "GameCorner,Route22Gate,Route23}.asm,\n" "constants/player_constants.asm, home/overworld.asm,\n" "gfx/title/*.png + gfx/splash/copyright.png,\n" "gfx/splash/*.png + gfx/intro/* + gfx/battle/move_anim_1.png\n" "+ engine/movie/{splash,intro}.asm,\n" "scripts/*.asm spinner tables,\n" "data/maps/{town_map_entries,town_map_order,names}.asm\n" "+ constants/map_constants.asm + engine/items/town_map.asm,\n" "data/credits/{credits_order,credits_text,credits_mons}.asm\n" "+ constants/credits_constants.asm + engine/movie/credits.asm\n" "+ gfx/credits/the_end.png,\n" "constants/script_constants.asm (SLOTS*, *_BUBBLE)\n" "+ gfx/slots/red_slots_{1,2}.png + gfx/emotes/*.png\n" "+ gfx/trade/* (InternalClockTradeAnim),\n" "+ engine/{slots/slot_machine,overworld/emotion_bubbles}.asm,\n" "scripts/ViridianCity.asm + engine/battle/core.asm,\n" "scripts/GameCorner.asm + text/GameCorner.asm,\n" "constants/menu_constants.asm + engine/items/inventory.asm,\n" "data/tilesets/{warp_carpet_tile_ids,bike_riding_tilesets}.asm,\n" "data/maps/dungeon_maps.asm,\n" "engine/overworld/player_state.asm,\n" "engine/battle/wild_encounters.asm"} util.write_lua(os.path.join(out_dir, "field.lua"), data, header="Ledges, Cut trees, water, trades, Fly spots, hidden items,\n" "slot machines, spinner arrow tiles, card key doors,\n" "hidden event extras (PCs/bench guys/statues/trash cans),\n" "forced bike/surf tiles, Seafoam currents, Game Corner\n" "poster, badge gates, preset names, dark maps, title assets,\n" "intro movie assets, town map, credits, slot symbols,\n" "emotion bubbles,\n" "old man catch demo, coin purchases, PC item capacity.") return data