From 908d3d73e40c5d240b8bc4aad36651ee1d6445f8 Mon Sep 17 00:00:00 2001 From: Decrypt <90518536+Decryptu@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:32:11 +0200 Subject: [PATCH 1/8] Throw the tutor's ball on Red, Blue and Yellow and walk Yellow to Lt. Surge `wBattleType` is read across onto Crystal's numbering at the wild-battle node: BATTLE_TYPE_OLD_MAN and Yellow's BATTLE_TYPE_PIKACHU share `DisplayBattleMenu`'s branch, so both are the Dude's tutorial with the raw byte and its tutor on the request. `LoadPlayerBackPic`'s `ProfOakPicBack` is Yellow's third `player_back` slot, which bumps the cache format. - `StartBattle` sends the player out on a normal battle alone: a Safari or a tutor's fight keeps the back pic up and draws no player HUD. The Safari had been sending the lead out. - The cursor `DisplayBattleMenu` and `DisplayListMenuIDLoop` draw themselves is the Dude's auto-input machine over the cartridge's own `DelayFrames`: 80, 50 and 80 on Red and Blue, 20 each on Yellow. The tutor's ball is the cartridge's POKE_BALL, not Crystal's. - `ItemUseBall`'s `.oldManBattle` lands the ball except under Yellow's EVENT_INITIAL_CATCH_TRAINING, where `$63` breaks out and `wCapturedMonSpecies` ends the battle anyway. - `OverworldLoop` reads `wCurOpponent` once the script has returned, so a row's battle stands behind the rest of the row. - A tutor with no party stands on the fallback party, never written back. - `_ItemUseText001` puts the item on the bottom row; `_ItemUseBallText05` is a `line` and a `cont`. - `wStringBuffer` outlives the row that filled it: Lt. Surge's receipt had printed a bare marker where TM24 goes. - Yellow's `OaksLabScript_RemoveParcel` ends on `call` and `ret`, its lab writes LIGHT_BALL_GSC over the starter's catch-rate byte and calls `DisablePikachuOverworldSpriteDrawing`; all three are read now, so the lab hands over PIKACHU and takes the parcel. - The story walk takes Yellow's EEVEE ball, Damian's CHARMANDER for HM01 and Yellow's own EVENT_GOT_DOME_FOSSIL, and reaches Lt. Surge. - `validate.gd` runs its topics on the first frame: the root is not inside the tree in `_initialize`, so a screen a topic adds is never ready. `gen1_battle.gd` drives the five tutor fights on the real overlay that way. --- game/battle/battle.gd | 9 --- game/battle/battle_screen.gd | 94 +++++++++++++++++++++------- game/battle/effect_commands.gd | 22 ------- game/gen1/gen1_importer.gd | 14 ++--- game/gen1/gen1_layout.gd | 69 +++++++++++++++++++-- game/gen1/gen1_world_importer.gd | 3 +- game/import/rom_cache.gd | 2 +- game/world/world_api.gd | 55 ++++++++++------- game/world/world_battle.gd | 13 +++- game/world/world_party_host.gd | 7 ++- game/world/world_screen.gd | 16 ++++- tests/unit/test_gen1_layout.gd | 33 ++++++++++ tests/unit/test_source_budget.gd | 2 +- tests/unit/test_world_api.gd | 23 +++++++ tools/checks/gen1_battle.gd | 101 +++++++++++++++++++++++++++++++ tools/checks/gen1_maps.gd | 46 +++++++------- tools/checks/gen1_pics.gd | 26 +++++--- tools/checks/gen1_walk.gd | 41 +++++++++++++ tools/preview_world.gd | 15 ++++- tools/preview_world_story.gd | 44 +++++++++++--- tools/validate.gd | 17 +++++- 21 files changed, 507 insertions(+), 145 deletions(-) diff --git a/game/battle/battle.gd b/game/battle/battle.gd index e9d4fd44..120224e7 100644 --- a/game/battle/battle.gd +++ b/game/battle/battle.gd @@ -3,7 +3,6 @@ extends RefCounted ## A battle: two parties, a turn at a time. Scene-free with randomness injected, ## so a whole battle can be fought in a test with no display. -## ## A turn answers with events carrying their own numbers, never a string: ## sentences, animation and draining bars are the screen's job. A side is a party ## and a wild encounter a party of one. A turn ending with somebody down says so @@ -751,7 +750,6 @@ static func use_item(item: int) -> Dictionary: ## `InitEnemyTrainer`: the class's own `TRNATTR_ITEM1` and `TRNATTR_ITEM2` into ## the two working slots, and then `IsGymLeader`'s own party walk. `NO_ITEM` is ## zero and is not carried. -## ## [param rewarded] is false for the two opponents `ReadTrainerParty` returns in ## front of, the Battle Tower's and a link partner's: neither reaches ## `ComputeTrainerReward`, and neither win branch pays money either. @@ -1513,12 +1511,10 @@ static func double_reward(amount: int) -> int: ## `BattleWon.give_money`, the whole of what a beaten trainer pays. -## ## [param reward] is [member battle_reward], a quarter of the prize: the Amulet ## Coin doubles that quarter, four quarters are handed out one at a time, and ## the two `.DoubleReward` calls at `.done` put the total back for the line that ## announces it. [param mom_flags] is `wMomSavingMoney`. -## ## Returns the two credits, the figure the line prints and which line it is. static func prize_money_split( reward: int, amulet: bool, mom_flags: int, moms_money: int, max_money: int @@ -1565,7 +1561,6 @@ func _send_out_animation(enemy_turn: bool, param: int) -> Dictionary: ## `SendOutMonText`, which picks one of four lines off how much of the opponent ## is left. Only the player is ever announced this way; the enemy has one line. -## ## The arithmetic is the source's own: the remaining HP times 25 over the top ## quarter of the maximum, both read as the cartridge reads them, so the answer ## is a percentage that has been through an eight-bit divisor. A maximum below @@ -2972,7 +2967,6 @@ func _act(side: int, slot: int, move_number: int, events: Array) -> void: ## The command interpreter: `DoMove`'s own read cycle over the list an effect ## byte picks, with `SkipToBattleCommand` and `endloop`'s rewind to `critical`. -## ## `ResetTurn`, used by Metronome, Mirror Move and Sleep Talk: the called move ## replaces the working one and starts its list from the beginning, without the ## once-per-action status gate. A fresh [Gen2Turn] is that clean move-struct copy, @@ -3035,7 +3029,6 @@ func run_move_effect(turn: Gen2Turn, depth: int = 0) -> void: ## calls, `RegionCheck` and `IsGymLeader`. Kept here rather than on the screen ## because every input is battle state: `wBattleType`, `wOtherTrainerClass`, ## `wOtherTrainerID`, `wTimeOfDay` and the map's landmark. -## ## `MUSIC_SUICUNE_BATTLE` exists on Crystal alone; the two `BATTLETYPE_` rows in ## front of the trainer check are the only place either game reaches it, and ## Gold and Silver never write those types. @@ -3112,7 +3105,6 @@ const LANDMARK_VICTORY_ROAD: int = 0x58 ## does everything below `KANTO_LANDMARK`, and so does Victory Road and every ## landmark above it, because `cp LANDMARK_VICTORY_ROAD / jr c, .kanto` only ## takes the Kanto branch below that row. -## ## The `LANDMARK_SPECIAL` backup lookup in front of it is ## [method Gen2WorldAPI.landmark_backup], which every caller of this resolves ## the landmark through. @@ -3125,7 +3117,6 @@ static func region_is_kanto(landmark_id: int, crystal: bool = true) -> bool: ## `PlayBattleMusic`'s answer: the track a battle opens on. -## ## [param landmark_id] is `GetWorldMapLocation`'s, [param trainer_class] and ## [param trainer_id] are `wOtherTrainerClass` and `wOtherTrainerID` (class 0 ## being a wild fight), and [param day_period] is `wTimeOfDay`. diff --git a/game/battle/battle_screen.gd b/game/battle/battle_screen.gd index aed0eec6..541b1cde 100644 --- a/game/battle/battle_screen.gd +++ b/game/battle/battle_screen.gd @@ -70,7 +70,6 @@ const INFLICTED: Dictionary = { ## `BattleCommand_Charge.UsedText` (data/text/common_2.asm), which picks its line ## by move number rather than by effect: Fly and Dig share an effect byte and do ## not share a sentence. -## ## The source's own `line` is a line break in a fixed-width box rather than part ## of the sentence, so these read as one flowing string the way every other ## message here does. @@ -938,18 +937,35 @@ func _tick_auto_input() -> void: if stage != _auto_input_stage: _auto_input_stage = stage _auto_input_polls = 0.0 - if stage == &"": + var streams: Dictionary = _auto_input_streams() + if not streams.has(stage): _auto_input = [] else: - _start_auto_input(DUDE_AUTO_INPUT[stage]) + _start_auto_input(streams[stage]) if _auto_input_index >= _auto_input.size(): return - _auto_input_polls += float(DUDE_POLLS_PER_FRAME.get(_auto_input_stage, 1.0)) + _auto_input_polls += 1.0 if _generation() == RomRegistry.GEN1 \ + else float(DUDE_POLLS_PER_FRAME.get(_auto_input_stage, 1.0)) while _auto_input_polls >= 1.0 and _auto_input_index < _auto_input.size(): _auto_input_polls -= 1.0 _spend_auto_input_poll() +## `DudeAutoInputs`, or the cursor `DisplayBattleMenu` and `DisplayListMenuIDLoop` +## draw themselves: FIGHT, ITEM, the one ball, each held `DelayFrames` long. +func _auto_input_streams() -> Dictionary: + if _generation() != RomRegistry.GEN1: + return DUDE_AUTO_INPUT + var frames: Array = Gen1Layout.TUTOR_FRAMES.get(_data.id, Gen1Layout.TUTOR_FRAMES[RomRegistry.RED]) + return { + &"menu": [ + [PokeButton.NONE, int(frames[0]) - 1], [PokeButton.DOWN, int(frames[1]) - 1], + [PokeButton.A, 0], + ], + &"pack": [[PokeButton.NONE, int(frames[2]) - 1], [PokeButton.A, 0]], + } + + func _spend_auto_input_poll() -> void: if _auto_input_delay > 0: _auto_input_delay -= 1 @@ -1252,10 +1268,15 @@ func _begin_world_battle(prepared: Dictionary, save: Gen2SaveData) -> void: ## The Dude's bag, not the player's, which is why the world hands none. func _set_up_dude_tutorial() -> void: + _stop_auto_input() + if _generation() == RomRegistry.GEN1: + var balls: Dictionary = {_tutor_ball(): int(Gen1Layout.TUTOR_BALLS.get(_data.id, 1))} + set_battle_pack([_tutor_ball()], balls) + set_capture_balls([_tutor_ball()], balls) + return ## `.DudeTutorial` forces TEXT_DELAY_MED over the player's own TEXT SPEED. if _box != null: _box.reveal_speed = 1.0 / (Gen2Options.FRAME_SECONDS * Gen2Options.TEXT_DELAY_MED) - _stop_auto_input() set_battle_pack(DUDE_PACK, DUDE_PACK_QUANTITIES) set_capture_balls([Gen2WorldPartyHost.ITEM_POKE_BALL], DUDE_PACK_QUANTITIES) @@ -1434,12 +1455,32 @@ func player_backpic_kind() -> String: ## `GetTrainerBackPic` has two: the player, and the old man who borrows the ## screen for the catching tutorial. if _data != null and _data.generation == RomRegistry.GEN1: - return Gen1Layout.PLAYER_BACKPICS[1 if _world_battle_tutorial else 0] + return _gen1_tutor().get("tutor", Gen1Layout.PLAYER_BACKPICS[0]) \ + if _world_battle_tutorial else Gen1Layout.PLAYER_BACKPICS[0] if _world_battle_tutorial: return "dude" return "kris" if player_is_female() else "chris" +func _gen1_tutor() -> Dictionary: + var values: Variant = _world_battle_request.get("values", _world_battle_request) + var raw: int = int((values as Dictionary).get("gen1_battle_type", 0)) \ + if values is Dictionary else 0 + return Gen1Layout.BATTLE_TYPES.get(raw, {}) + + +## Crystal's POKE_BALL is a TOWN MAP on the other cartridge. +func _tutor_ball() -> int: + return Gen1Layout.ITEM_POKE_BALL if _generation() == RomRegistry.GEN1 \ + else Gen2WorldPartyHost.ITEM_POKE_BALL + + +func _tutor_name() -> String: + if _generation() != RomRegistry.GEN1: + return DUDE_NAME + return String(Gen1Layout.TUTOR_NAMES.get(String(_gen1_tutor().get("tutor", "")), DUDE_NAME)) + + ## `wPlayerGender`'s `PLAYERGENDER_FEMALE_F`. Gold and Silver have one player ## character and no gender screen, so the answer there is always the male one. func player_is_female() -> bool: @@ -1527,6 +1568,10 @@ func _build_entrance() -> void: _entrance_stages.append({"apply": ENTRANCE_ENEMY_HUD}) # `DoBattle`'s own `ld c, 40`, then `SlideBattlePicOut` and `SendOutMonText`. _entrance_stages.append({"delay": PLAYER_ENTRANCE_FRAMES}) + ## `StartBattle` sends the player out on a normal battle alone; a Safari or + ## a tutor's fight opens `DisplayBattleMenu` with the back pic still up. + if _gen1_special_battle(): + return _entrance_stages.append({"slide": Gen2Battle.PLAYER}) _entrance_stages.append({ "message": SEND_OUT_LINES[ @@ -1913,7 +1958,7 @@ const GEN1_BREAK_FREE_TEXT: Array[String] = [ ## `Text_GotchaMonWasCaught` and `_ItemUseBallText05`. const CAUGHT_TEXT: String = "Gotcha! %s was caught!" -const GEN1_CAUGHT_TEXT: String = "All right! %s was\ncaught!" +const GEN1_CAUGHT_TEXT: String = "All right!\n%s was%scaught!" ## `SendOutMonText`'s four texts, in [constant Gen2Battle.SEND_OUT_GO]'s order. const SEND_OUT_LINES: Array[String] = [ @@ -2057,7 +2102,6 @@ func _begin_animation(event: Dictionary) -> void: ## The doll the animation would have drawn, written straight into the picture ## when the battle-scene option is off and the script never runs. -## ## `BattleCommand_LowerSub` and `..._RaiseSub` both branch to their own `noanim` ## routine on `_CheckBattleScene`, and `BattleCommand_Substitute`'s own ## `.no_anim` calls `RaiseSubNoAnim`, so the three animation parameters answer @@ -3243,10 +3287,13 @@ func complete_capture(result: Dictionary) -> Dictionary: var wobbles: int = clampi(int(result.get("wobbles", 0)), 0, 3) var caught: bool = bool(result.get("caught", false)) + ## `wCapturedMonSpecies`, which Yellow's initial catch training leaves standing + ## on a ball that broke out. + _capture_terminal = bool(result.get("ends_battle", false)) if caught: _box_queue.append( - (GEN1_CAUGHT_TEXT if _generation() == RomRegistry.GEN1 else CAUGHT_TEXT) - % _name_of(_enemy) + GEN1_CAUGHT_TEXT % [_name_of(_enemy), Gen2TextStream.SCROLL_BREAK] + if _generation() == RomRegistry.GEN1 else CAUGHT_TEXT % _name_of(_enemy) ) ## `.catch_bug_contest_mon` runs after `Text_GotchaMonWasCaught`, and ## `BugContest_SetCaughtContestMon`'s `.firstcatch` says a second line. @@ -3432,9 +3479,9 @@ func _play_poke_flute() -> void: ## animation behind it, and the next thing said is already the outcome. func _item_used_text(item: int) -> String: if _generation() == RomRegistry.GEN1: - ## `_ItemUseText001` and `_ItemUseText002` with the name between them: - ## one line, no "the", and an exclamation where Crystal has a stop. - return "%s used %s!" % [_player_label(), _item_name(item)] + ## `_ItemUseText001`, `text_low`, `_ItemUseText002`: no "the", the item on + ## the bottom row and an exclamation where Crystal has a stop. + return "%s used\n%s!" % [_player_label(), _item_name(item)] return "%s used the\n%s." % [_player_label(), _item_name(item)] @@ -3445,6 +3492,13 @@ func _is_wild_battle() -> bool: return values is Dictionary and StringName((values as Dictionary).get("kind", &"")) == &"wild" +## `wBattleType` on a Generation 1 cartridge: every value but zero is a fight +## the player's own Pokemon never joins. +func _gen1_special_battle() -> bool: + return _generation() == RomRegistry.GEN1 and _battle != null \ + and _battle.battle_type != Gen2Battle.BATTLETYPE_NORMAL + + ## `wBattleType` being BATTLETYPE_CONTEST, which is what makes the menu the ## contest's own and the ball a Park Ball. func _is_bug_contest_battle() -> bool: @@ -3479,7 +3533,6 @@ func _capture_failure(reason: StringName) -> Dictionary: ## `AskGiveNicknameText`, `YesNoBox` and `NamingScreen`, which `PokeBallEffect` ## runs for a caught Pokemon whether it went to the party or to the box, and ## which `.catch_bug_contest_mon` and `.FinishTutorial` both jump past. -## ## True while the prompt stands, so the pump that called this waits for it. func _open_capture_nickname() -> bool: if _capture_nickname_host != null: @@ -3757,7 +3810,6 @@ func _rules() -> Gen2Rules: ## Tries to run, which is `BattleMenu_Run` and settles before the turn does. -## ## Offered in a trainer battle too, because the cartridge offers it there and ## answers with its own refusal rather than greying the entry out. func run_from_battle() -> void: @@ -3770,7 +3822,6 @@ func run_from_battle() -> void: ## Swaps the player's Pokémon for the next one that is standing, as a turn. -## ## The enemy attacks while it happens, because a switch is not free: this is the ## whole point of the ordering rule, and it is worth being able to look at. func switch_player() -> void: @@ -4148,7 +4199,8 @@ func _finish_battle() -> void: func sync_live_party() -> bool: if _source_save == null or _battle == null or _data == null: return false - if _battle.in_battle_tower: + ## `.FinishTutorial` keeps nothing, and the tutor may stand on a stand-in party. + if _battle.in_battle_tower or _world_battle_tutorial: return true var fought: Gen2SaveData = Gen2SaveBattleAdapter.from_battle_party( _data.id, _data.sha1, _source_save.slot, _battle.party(Gen2Battle.PLAYER), @@ -4593,7 +4645,6 @@ func _take_turn_with_slot(slot: int) -> void: ## Opens `OfferSwitch`'s yes/no, which only SHIFT ever reaches, and answers ## whether there was a question to put up. -## ## Answered before a replacement for the same reason a Baton Pass is: the turn it ## stopped has not finished, and nothing behind it can be asked yet. func _answer_switch_offer() -> bool: @@ -4942,7 +4993,7 @@ func _enemy_label() -> String: ## sentence. func _player_label() -> String: if _world_battle_tutorial: - return DUDE_NAME + return _tutor_name() if _source_save != null and not _source_save.player_name.is_empty(): return _source_save.player_name return Gen2OakSpeech.DEFAULT_MALE @@ -5044,7 +5095,6 @@ func _draw_menu_layer() -> void: ## `DisplayCaughtContestMonStats`: the screen is cleared and the two boxes are ## drawn over it, STOCK #MON above THIS #MON, each with a name, a level and a ## HEALTH number, with `PlaceYesNoBox`' own box beside the lower one. -## ## One image on the menu layer, the way the party page is: all three boxes go ## into the tilemap on the cartridge too, and the text box under them is this ## screen's own, which is where `ContestAskSwitchText` is already being said. @@ -5289,7 +5339,6 @@ static func _count_row(item_name: String, quantity: int) -> Dictionary: ## `ItemSubmenu`'s USE/QUIT box, which stands over the list the row was chosen ## from and is what the second A press answers. -## ## On [member _info_layer] rather than on the menu layer, which is under the ## list: `LoadMenuHeader` draws this box into the tilemap after the pack's own, ## so it covers the rows it was opened from. @@ -5469,7 +5518,6 @@ func _replace_the_fallen() -> bool: ## The next event, with whatever it changes applied first. -## ## Every number drawn comes from the event, not the Pokémon: the turn has already ## resolved by the time the first event is shown, so reading the Pokémon would ## draw the end of the turn during the middle of it. @@ -6241,7 +6289,7 @@ func _button_pack(button: int) -> bool: ## and throws a POKE BALL anyway, so there is no USE submenu. if _world_battle_tutorial: _pack_selecting = false - _throw_ball(Gen2WorldPartyHost.ITEM_POKE_BALL, &"pack") + _throw_ball(_tutor_ball(), &"pack") else: _open_pack_action(&"pack") PokeButton.B: diff --git a/game/battle/effect_commands.gd b/game/battle/effect_commands.gd index 621ed63a..6e2ce3c5 100644 --- a/game/battle/effect_commands.gd +++ b/game/battle/effect_commands.gd @@ -829,7 +829,6 @@ static func _rage_damage(turn: Gen2Turn) -> void: ## takes, and the line that says so. It stands behind `checkfaint`, which ends ## the move when the target has fallen, so a Rage that faints does not build; a ## substitute is not a gate, the doll spending the hit still counting. -## ## `inc a / ret z` is the saturation: 255 increments to 0 and is not stored. static func _build_opponent_rage(turn: Gen2Turn) -> void: if turn.missed: @@ -855,7 +854,6 @@ static func _check_future_sight(turn: Gen2Turn) -> void: ## Stores damage after DamageCalc and before DamageVariation, exactly where the ## source copies `wCurDamage` into the side's delayed word, and ends the move. -## ## `.failed` is a count still running: the move announces and then fails, which is ## what a second Future Sight does. static func _future_sight(turn: Gen2Turn) -> void: @@ -957,7 +955,6 @@ static func _stab(turn: Gen2Turn) -> void: ## `BattleCommand_DamageVariation`: the 85% to 100% spread, last. -## ## Nothing below two is touched and, because the routine returns before ## `BattleRandom`, nothing below two draws either, so a move that worked out to ## nothing moves no generator. @@ -1049,7 +1046,6 @@ static func _present(turn: Gen2Turn) -> void: ## `BattleCommand_FuryCutter`: the damage doubled once per consecutive hit, ## capped at five turns' worth, and the count reset by a miss. -## ## Sits between `stab` and `damagevariation`, so the doubling lands on the ## matched-up damage and the spread is taken from the doubled figure. static func _fury_cutter(turn: Gen2Turn) -> void: @@ -1152,7 +1148,6 @@ static func _tri_status_chance(turn: Gen2Turn) -> void: ## `BattleCommand_Defrost`: Flame Wheel and Sacred Fire thaw whoever used them. -## ## The user, not the target, which is what tells this apart from the `Defrost` ## subroutine [method _defrost] is. It clears the freeze bit rather than the ## whole status byte, which comes to the same thing while a freeze is the only @@ -1903,7 +1898,6 @@ static func _effect_chance(turn: Gen2Turn) -> void: ## Puts a status on the defender, or fails. One at a time: a Pokémon already ## carrying something is refused, as is one whose type makes it immune, and only ## sleep is rolled for a length. -## ## The four `*Target` commands share their order: existing status, weather, type, ## and only then `wEffectFailed`. Last is not cosmetic, the first step being the ## one that does something besides refuse: a burn whose roll failed still reaches @@ -2228,7 +2222,6 @@ static func _check_charge(turn: Gen2Turn) -> void: ## `BattleCommand_Charge`: the charging turn. It locks the user in, says its own ## line and ends the move; Skull Bash carries on at [constant END_TURN], which is ## where its Defense raise sits. -## ## A user that is asleep gets `PrintButItFailed` instead, which is Sleep Talk ## reaching a two-turn move: the source spends `movedelay` and `raisesub` before ## the line. @@ -3045,7 +3038,6 @@ static func _destiny_bond(turn: Gen2Turn) -> void: ## `BattleCommand_ForceSwitch`: Whirlwind and Roar, with two endings. Against a ## trainer the target's side switches to a random standing member; against a wild ## the battle *ends* in either direction, `SetBattleDraw` making both a draw. -## ## The source's `.trainer` and `.vs_trainer` are one routine here, every ## difference between them being which side is read: the user's level against the ## target's, the user having moved second, and a random member of that party. @@ -3226,7 +3218,6 @@ static func _foresight(turn: Gen2Turn) -> void: ## `BattleCommand_LockOn`: Lock On and Mind Reader, one command. The flag goes on ## the target, and [method _check_hit] spends it on the next hit check made ## against that Pokémon, whoever makes it. -## ## The one refusal is a target behind a doll, and it prints ## `PrintDidntAffect` rather than "But it failed!". static func _lock_on(turn: Gen2Turn) -> void: @@ -3321,7 +3312,6 @@ static func _thief(turn: Gen2Turn) -> void: ## `BattleCommand_Pursuit`: twice the finished figure against a side that is ## leaving, saturating at a word rather than wrapping. -## ## The doubling is all this command is. What makes Pursuit hit the Pokémon on its ## way out is `PursuitSwitch`, which runs the whole move in front of the switch; ## see [method Gen2Battle.take_actions]. @@ -3336,7 +3326,6 @@ static func _pursuit(turn: Gen2Turn) -> void: ## stats. `damagecalc` never sees `damagestats`, so no item, screen, stage or ## truncation touches either figure, and there is no `stab`, so the modifier ## stays `EFFECTIVE` and no effectiveness is announced. -## ## A member with no health or any status takes `.beatup_fail`, which skips ## forward to `buildopponentrage`: the hit is not spent and the loop carries on. static func _beat_up(turn: Gen2Turn) -> void: @@ -3400,7 +3389,6 @@ static func _base_stat(turn: Gen2Turn, species: int, key: String) -> int: ## `BattleCommand_CheckSafeguard`: the target's own Safeguard refusing a status ## move outright, with `SafeguardProtectText` and the move ended. -## ## `wAttackMissed` is set before the text, so everything behind this in the list ## is skipped and the move counts as a miss for whatever reads that back. static func _check_safeguard(turn: Gen2Turn) -> void: @@ -3420,7 +3408,6 @@ static func _safeguard_refuses(turn: Gen2Turn, side: int) -> bool: ## `BattleCommand_Heal`: Recover, Softboiled and Milk Drink take back half the ## maximum; Rest takes back all of it and pays for that with two turns asleep. -## ## The full-HP refusal is checked before anything else, so Rest at full health ## fails and stays awake even when there is a status sitting on it that sleeping ## would have cleared. Rest writes `REST_SLEEP_TURNS + 1` over the whole status @@ -3449,7 +3436,6 @@ static func _heal(turn: Gen2Turn) -> void: ## `BattleCommand_TimeBasedHealContinue`: Morning Sun, Synthesis and Moonlight. -## ## Half the maximum by default. One step down the table outside the move's own ## time of day, which is the cartridge's real rule: matching the clock buys ## nothing, missing it costs. Then one step up in sun, or one step down in any @@ -3496,7 +3482,6 @@ static func _heal_fraction(max_hp: int, index: int) -> int: ## Thunder's accuracy for this turn: `50 percent + 1` (128) in sun, and ## `100 percent` (255) in rain. -## ## Written on the turn rather than on the move, because the cartridge writes it ## into `wPlayerMoveStruct`, a per-turn copy, while [member Gen2Turn.move] is the ## cached row every future Thunder would read. @@ -3518,7 +3503,6 @@ static func _skip_sun_charge(turn: Gen2Turn) -> void: ## `PlayFXAnimID`. Nothing is drawn here: the animation is written down as an ## event at its own place in the turn and the screen is what spends frames on it. -## ## [param on_opponent] is `PlayOpponentBattleAnim`'s pair of ## `BattleCommand_SwitchTurn` calls: the same event with `hBattleTurn` inverted ## for the length of the animation, so it plays on the target rather than on @@ -3551,7 +3535,6 @@ static func _play_opponent_battle_anim(turn: Gen2Turn, index: int) -> void: ## `BattleCommand_MoveAnimNoSub`: the damage flash aimed at whoever was hit, the ## animation param cleared or alternated, and then the move's own animation. -## ## A miss falls to `BattleCommand_MoveDelay` and plays nothing. That branch is ## structural here rather than reached: [method _check_hit] ends the move, so no ## list gets this far after one. @@ -3580,7 +3563,6 @@ static func _move_anim(turn: Gen2Turn) -> void: ## `BattleCommand_LowerSub`: the user's doll dropped out of the way of whatever ## is about to be drawn, as the SUBSTITUTE animation's own `.dropsub` branch. -## ## Nothing is dropped for a user with no doll up, and nothing is dropped on the ## turn a two-turn move is charging either: `CheckUserIsCharging` is what ## [method _do_turn] reads as [member Gen2Turn.locked] or @@ -3646,7 +3628,6 @@ static func _stat_change_anim(turn: Gen2Turn, after_anim: int) -> void: ## Which of the five status commands carries an `AnimateCurrentMove` of its own. -## ## `BattleCommand_SleepTarget`, `..._Poison` and `..._Paralyze` are the status ## moves' own commands and do; `..._PoisonTarget`, `..._ParalyzeTarget`, ## `..._BurnTarget` and `..._FreezeTarget` are the secondary-effect commands and @@ -3664,7 +3645,6 @@ static func _status_move_animates(turn: Gen2Turn, flag: int) -> bool: ## Which status animation `PlayOpponentBattleAnim` plays on the target, or -1. -## ## The four secondary-effect commands each play one and the primary status moves' ## commands play none, each ending at `AnimateCurrentMove`; Toxic reaches ## `.apply_poison` too, so [method _toxic_target] plays none either. The exact @@ -3700,7 +3680,6 @@ static func _animate_current_move(turn: Gen2Turn) -> void: ## `BattleCommand_HeldFlinch`: a King's Rock on the attacker makes an ordinary ## attack flinch, out of the item's own parameter. -## ## The `wAttackMissed` guard is structural here, since [method _check_hit] ends ## the move on a miss and [method _check_faint] ends it on a KO, so this step is ## only ever reached by a hit that left the target standing. The Substitute check @@ -3859,7 +3838,6 @@ static func _stat_message(turn: Gen2Turn) -> void: ## only place [code]data/moves/effects.asm[/code] follows a message step with ## [code]statdownfailtext[/code]; an on-hit drop blocked by Mist fails silently, ## like any on-hit drop that misses its roll. -## ## Mist gets its own line, because ## [code]BattleCommand_StatDownFailText[/code] prints ## [code]ProtectedByMistText[/code] here rather than "won't go any lower". diff --git a/game/gen1/gen1_importer.gd b/game/gen1/gen1_importer.gd index 1445d568..c2cd3d5a 100644 --- a/game/gen1/gen1_importer.gd +++ b/game/gen1/gen1_importer.gd @@ -1796,14 +1796,10 @@ func _import_pics( trainers, trainer_class - 1 ) - var player_back: Dictionary = PokeTiles.new_atlas( - Gen1Layout.BACKPIC_TILES, Gen1Layout.PLAYER_BACKPICS.size() - ) - for slot: int in Gen1Layout.PLAYER_BACKPICS.size(): - _decode_pic( - codec, rom, int(layout["pic_%s_back" % Gen1Layout.PLAYER_BACKPICS[slot]]), - player_back, slot - ) + var backpics: Array[String] = Gen1Layout.player_backpics(layout) + var player_back: Dictionary = PokeTiles.new_atlas(Gen1Layout.BACKPIC_TILES, backpics.size()) + for slot: int in backpics.size(): + _decode_pic(codec, rom, int(layout["pic_%s_back" % backpics[slot]]), player_back, slot) var player_front: Dictionary = PokeTiles.new_atlas( Gen1Layout.FRONTPIC_MAX_TILES, PLAYER_FRONTPICS.size() @@ -1815,7 +1811,7 @@ func _import_pics( var wanted: Dictionary = { "front": Gen1Layout.SPECIES_COUNT, "back": Gen1Layout.SPECIES_COUNT, "trainers": Gen1Layout.TRAINER_CLASS_COUNT, - "player_back": Gen1Layout.PLAYER_BACKPICS.size(), + "player_back": backpics.size(), "player_front": PLAYER_FRONTPICS.size(), } var atlases: Dictionary = { diff --git a/game/gen1/gen1_layout.gd b/game/gen1/gen1_layout.gd index 202c3116..7f80e02b 100644 --- a/game/gen1/gen1_layout.gd +++ b/game/gen1/gen1_layout.gd @@ -1194,7 +1194,8 @@ const SCRIPT_CALLS: Array[String] = [ "delay_frame", "delay_frames", "delay_3", "play_default_music", "check_map_trainers", "player_coords_in_array", "start_trainer_battle", "end_trainer_battle", "force_bike_or_surf", "play_music", "stop_all_music", "random", "set_sprite_position_2", "set_sprite_image", - "set_sprite_image_2", "enable_pikachu_drawing", "check_pikachu_following", + "set_sprite_image_2", "enable_pikachu_drawing", "disable_pikachu_drawing", + "check_pikachu_following", "set_sprite_facing", "set_sprite_facing_delay", "sprite_stay", "move_sprite", "decode_rle", "decode_arrow_movement", "update_gym_gates", "serial_connect", "fill_memory", "save_end_battle_text", "engage_map_trainer", @@ -1310,6 +1311,33 @@ const BATTLE_OUTCOME_SOURCES: Dictionary = { "is_in_battle": [0xFF, BATTLE_OUTCOME_LOST], "battle_result": [2, BATTLE_OUTCOME_ESCAPED], } +## `wBattleType` read across onto Crystal's numbering. The old man and Yellow's +## Prof. Oak share `DisplayBattleMenu`'s branch, so both are the Dude's tutorial +## here; `tutor` names `LoadPlayerBackPic`'s pic and `.oldManName`'s row. +const BATTLE_TYPE_OLD_MAN: int = 1 +const BATTLE_TYPE_SAFARI: int = 2 +const BATTLE_TYPE_PIKACHU: int = 4 +const BATTLE_TYPES: Dictionary = { + BATTLE_TYPE_OLD_MAN: {"battle_type": Gen2Battle.BATTLETYPE_TUTORIAL, "tutor": "old_man"}, + BATTLE_TYPE_SAFARI: {"battle_type": Gen2Battle.BATTLETYPE_SAFARI}, + BATTLE_TYPE_PIKACHU: {"battle_type": Gen2Battle.BATTLETYPE_TUTORIAL, "tutor": "prof_oak"}, +} +const TUTOR_NAMES: Dictionary = {"old_man": "OLD MAN", "prof_oak": "PROF.OAK"} +## `wCurOpponent` as each tutor's script writes it, by dex number. +const TUTOR_WILDS: Dictionary = { + RomRegistry.RED: {BATTLE_TYPE_OLD_MAN: 13}, RomRegistry.BLUE: {BATTLE_TYPE_OLD_MAN: 13}, + RomRegistry.YELLOW: {BATTLE_TYPE_OLD_MAN: 19, BATTLE_TYPE_PIKACHU: 25}, +} +const TUTOR_WILD_LEVEL: int = 5 +## `OldManItemList` is fifty balls where Yellow's `SimulatedInputBattleItemList` is one. +const TUTOR_BALLS: Dictionary = {RomRegistry.RED: 50, RomRegistry.BLUE: 50, RomRegistry.YELLOW: 1} +## `DisplayBattleMenu`'s two `DelayFrames` and `DisplayListMenuIDLoop`'s one. +const TUTOR_FRAMES: Dictionary = { + RomRegistry.RED: [80, 50, 80], RomRegistry.BLUE: [80, 50, 80], + RomRegistry.YELLOW: [20, 20, 20], +} +## Yellow's `ItemUseBall` answers `$63` while EVENT_INITIAL_CATCH_TRAINING stands. +const INITIAL_CATCH_TRAINING_EVENT: Dictionary = {RomRegistry.YELLOW: 47} ## Routines named by a full ROM offset, the same address in another bank being another routine. const SCRIPT_BANKED_CALLS: Array[String] = [ "coin_box", "music_rival_start", "music_rival_tempo", "schedule_pikachu_spawn", @@ -1337,7 +1365,7 @@ const SCRIPT_SILENT_CALLS: Array[String] = [ ## `SetSpritePosition2` puts back what `GetSpritePosition2` saved on the same ## visit, which the map's own table answers here; the image index is drawn. "set_sprite_position_2", "set_sprite_image", "set_sprite_image_2", - "enable_pikachu_drawing", + "enable_pikachu_drawing", "disable_pikachu_drawing", ## Red and Blue spell `StopAllMusic` as `PlaySound`; only Yellow has a routine. "play_music", "stop_all_music", ## A wait is frames of nothing and the map music is nobody's here. The three @@ -1415,6 +1443,8 @@ const SCRIPT_SILENT_STORES: Array[String] = [ "menu_watched_keys", "last_menu_item", "menu_item_to_swap", "print_item_prices", "list_menu_id", "filtered_bag_count", "walk_bike_surf_state_copy", "pikachu_spawn_state", + ## `AddPartyMon`'s catch-rate byte, read back by a Time Capsule alone. + "party_mon_1_catch_rate", ] ## The same over two bytes; `LoadItemList` already left the list itself. const SCRIPT_SILENT_WORDS: Array[String] = ["list_pointer"] @@ -1757,9 +1787,8 @@ const TRAINER_PIC_TILES: int = 7 const FRONTPIC_MAX_TILES: int = 7 const BACKPIC_TILES: int = 4 -## `GetTrainerBackpic`'s counterpart, in the `player_back` atlas's slot order: -## the player, and the old man who borrows the screen for the catching tutorial. -const PLAYER_BACKPICS: Array[String] = ["player", "old_man"] +## `LoadPlayerBackPic`'s three in atlas slot order; Red and Blue have no Prof. Oak. +const PLAYER_BACKPICS: Array[String] = ["player", "old_man", "prof_oak"] const RED_BLUE: Dictionary = { "species_names": 0x1C21E, @@ -2187,6 +2216,7 @@ const RED_BLUE: Dictionary = { "route23_copy_badge_text": 0x5125D, "add_party_mon": 0x3927, "mon_data_location": 0xCC49, + "party_mon_1_catch_rate": 0xD172, "beat_gym_flags": 0xD72A, "gym_leader_no": 0xD05C, "random_add": 0xFFD3, @@ -2460,6 +2490,7 @@ const YELLOW: Dictionary = { ## Yellow moved both back pics out of "Pics 4" and into their own bank. "pic_player_back": 0xF43B1, "pic_old_man_back": 0xF4441, + "pic_prof_oak_back": 0xF44D2, "pic_player_front": 0x11A97, "pic_shrink_1": 0x11B96, "pic_shrink_2": 0x11BF0, @@ -2625,6 +2656,7 @@ const YELLOW: Dictionary = { "route23_copy_badge_text": 0x51216, "add_party_mon": 0x391C, "mon_data_location": 0xCC49, + "party_mon_1_catch_rate": 0xD171, "beat_gym_flags": 0xD729, "gym_leader_no": 0xD05B, "random_add": 0xFFD3, @@ -2657,6 +2689,7 @@ const YELLOW: Dictionary = { "pikachu_spawn_state": 0xD430, "schedule_pikachu_spawn": 0xFC4FA, "enable_pikachu_drawing": 0x1525, + "disable_pikachu_drawing": 0x152D, "check_pikachu_following": 0x154A, "player_moving_direction": 0xD527, "play_music": 0x2211, @@ -2906,6 +2939,32 @@ static func flute_counts_wild(id: StringName) -> bool: return id == RomRegistry.YELLOW +static func player_backpics(layout: Dictionary) -> Array[String]: + var out: Array[String] = [] + for name: String in PLAYER_BACKPICS: + if layout.has("pic_%s_back" % name): + out.append(name) + return out + + +static func battle_type_values(raw: int) -> Dictionary: + var row: Dictionary = BATTLE_TYPES.get(raw, {}) + if row.is_empty(): + return {} + var out: Dictionary = {"battle_type": int(row["battle_type"]), "gen1_battle_type": raw} + if row.has("tutor"): + out["tutorial"] = true + out["can_lose"] = false + return out + + +## `.oldManBattle`'s throw. +static func tutorial_ball_lands(id: StringName, raw: int, event_active: Callable) -> bool: + if raw != BATTLE_TYPE_OLD_MAN or not INITIAL_CATCH_TRAINING_EVENT.has(id): + return true + return not bool(event_active.call(int(INITIAL_CATCH_TRAINING_EVENT[id]))) + + ## `CheckIfInOutsideMap`: which maps write `wLastMap` on the way out of them. static func is_outside_tileset(tileset: int) -> bool: return tileset == TILESET_OVERWORLD or tileset == TILESET_PLATEAU diff --git a/game/gen1/gen1_world_importer.gd b/game/gen1/gen1_world_importer.gd index 3522896c..9d1fdc43 100644 --- a/game/gen1/gen1_world_importer.gd +++ b/game/gen1/gen1_world_importer.gd @@ -4849,7 +4849,8 @@ static func _script_bag_scan(ctx: Dictionary, at: int, out: Array) -> int: if item < 1 and op == Gen1Layout.SCRIPT_CP_N \ and rom.u8(at + offset + 1) != Gen1Layout.MAP_COORD_END: item = rom.u8(at + offset + 1) - taken = taken or (op == Gen1Layout.SCRIPT_JP \ + ## Yellow spells the tail `call` and `ret` where Red and Blue `jp`. + taken = taken or (op in [Gen1Layout.SCRIPT_JP, Gen1Layout.SCRIPT_CALL] \ and rom.u16le(at + offset + 1) == int(layout.get("remove_item_from_inventory", -1))) if item < 1 or not taken: return SCRIPT_SHAPE_REFUSED diff --git a/game/import/rom_cache.gd b/game/import/rom_cache.gd index 43b9322d..76757b02 100644 --- a/game/import/rom_cache.gd +++ b/game/import/rom_cache.gd @@ -77,7 +77,7 @@ const BYTES_KEY: String = "bytes" ## a dump the owner still has, so re-importing costs a few seconds and a ## migration would have to carry every past shape forever. Nothing but the cache ## is thrown away, since saves live under their own root. -const FORMAT_VERSION: int = 128 +const FORMAT_VERSION: int = 129 ## What [method state] answers. A stale cache is told from a missing one because ## they need different things said to whoever is looking at it: one is a diff --git a/game/world/world_api.gd b/game/world/world_api.gd index 92904af6..f8dcca6d 100644 --- a/game/world/world_api.gd +++ b/game/world/world_api.gd @@ -2,7 +2,6 @@ class_name Gen2WorldAPI extends RefCounted ## Scene-free runtime access to one imported Generation 2 map. -## ## The API works in walk cells: every cell is a 2x2 group of 8x8 graphics ## tiles. It owns player position, camera framing and live object state, while ## event flags are supplied through a separate scene-free world state. @@ -558,7 +557,6 @@ func clear_map_name_sign() -> void: ## `InitMapNameSign`, which every map setup script but the submenu's reaches. -## ## The sign is Crystal's own screen: pokegold ships neither `MapEntryFrameGFX` ## nor the routine, so the whole decision is skipped there. `wPrevLandmark` is ## written on both branches, which is what makes a walk through a gate silent on @@ -1138,7 +1136,6 @@ func _clear_player_step() -> void: ## Spends one hardware frame of the player's walk-step offset. -## ## This only shrinks a presentation offset that starts and ends at player_cell; ## it never changes player_cell, collision or event results, and a caller that ## never starts a step sees no difference. @@ -3531,7 +3528,6 @@ func tile_index_at(tile_x: int, tile_y: int) -> int: ## Returns the visible 20x18 graphics-tile page in row-major order. Map padding ## is expanded through [method drawn_block_at], just like LoadMetatiles. -## ## This is [method tile_index_at] for 360 tiles, written out rather than called ## 360 times: it is on the draw path, and the per-tile call did the same block ## division and bounds check for every tile of the same block row. @@ -3613,7 +3609,6 @@ static func in_hardware_buffer(map: Gen2WorldMap, block_x: int, block_y: int) -> ## Every map the connection graph reaches from the current one, keyed ## `"group:number"`, each with its origin in the current map's block ## coordinates. The current map is not in it: it is the origin. -## ## Built once per map load and kept, since the graph is header data and nothing ## a run does moves a map. Ordered nearest first, so a caller that draws them in ## order draws the far ones under the near ones. @@ -3684,7 +3679,6 @@ static func connection_origin_blocks( ## The same fold for a map that is not the loaded one, which is what a battle ## staged on a map has: [Gen2BattleWorldContext] names the map and hands over no ## world, deliberately, so there is no `current_map` to read. -## ## [param block_overrides] is a live `changeblock` table, keyed as ## [method _block_key] keys one. A caller with no world has none, and a map with ## no world has had no block edited. @@ -3991,7 +3985,6 @@ const GEN1_SCRIPT_NODES: Dictionary = { ## The raw cartridge permission byte at a walk cell. -## ## The imported grid already holds the code the tileset gave each cell, so it is ## the answer unless a changeblock has replaced the block this cell belongs to. ## An overridden block has to be looked up in the tileset instead, because the @@ -4424,17 +4417,23 @@ func _gen1_node_battle_outcome(node: Dictionary, steps: Array, run: Dictionary) func _gen1_node_wild_battle(node: Dictionary, steps: Array, _run: Dictionary) -> bool: - steps.append({"type": &"request", "values": { - "kind": &"battle_requested", - "values": { - "kind": &"wild", - "pokemon": int(node["species"]), - "level": int(node["level"]), - }, - }}) + var values: Dictionary = { + "kind": &"wild", "pokemon": int(node["species"]), "level": int(node["level"]), + } + values.merge(Gen1Layout.battle_type_values(int(node.get("battle_type", 0)))) + steps.append({"type": &"request", "values": {"kind": &"battle_requested", "values": values}}) return true +func gen1_tutorial_ball_lands() -> bool: + if data == null or state == null: + return true + var values: Dictionary = pending_runtime_request().get("values", {}) + return Gen1Layout.tutorial_ball_lands( + data.id, int(values.get("gen1_battle_type", 0)), state.is_event_flag_active + ) + + ## `DecodeArrowMovementRLE`: the arrow tile the player stands on queues its own ## legs, and a cell with no row of its own leaves the map's trainers alone. func _gen1_node_arrow_movement(node: Dictionary, steps: Array, run: Dictionary) -> bool: @@ -5157,10 +5156,11 @@ func _gen1_node_map_text(node: Dictionary, steps: Array, run: Dictionary) -> boo var nodes: Variant = row.get("script", []) if nodes is Array and not (nodes as Array).is_empty(): return _gen1_resolve_script(nodes as Array, steps, run) - var text: String = gen1_filled_text(String(row.get("text", ""))) - if text.is_empty(): + if String(row.get("text", "")).is_empty(): return false - steps.append({"type": &"text", "text": text}) + ## `wStringBuffer` outlives the row that filled it: Lt. Surge's receipt is + ## the `DisplayTextID` after his `GiveItem`. + steps.append(_gen1_script_box(row, String(run.get("named", "")), run.get("buffers", {}))) return true @@ -6286,6 +6286,7 @@ func _gen1_step(type: StringName) -> Dictionary: ## row writes takes no turn of its own and is spent on the way past, and the ## balance window one of those draws rides on the result behind it. func _gen1_result() -> Array: + _gen1_battle_last() var events: Array = [] while not _gen1_steps.is_empty() and _gen1_written(_gen1_steps[0], events): _gen1_steps.pop_front() @@ -6298,6 +6299,19 @@ func _gen1_result() -> Array: return [result] +## `OverworldLoop` reads `wCurOpponent` once the script has returned, so the +## rest of a row stands in front of its battle: Yellow's initial catch training +## sets its event behind the store and the throw reads it. +func _gen1_battle_last() -> void: + var battles: Array = [] + var rest: Array = [] + for step: Dictionary in _gen1_steps: + var request: Dictionary = step.get("values", {}) if step.get("type", &"") == &"request" else {} + (battles if StringName(request.get("kind", &"")) == &"battle_requested" else rest).append(step) + if not battles.is_empty() and not rest.is_empty(): + _gen1_steps = rest + battles + + ## `SetLastBlackoutMap`, whose whole body is the rest-house list: healing in one ## of the Safari Zone's three leaves the map a blackout lands on where it was. ## -1 is no outdoor map walked out of rather than a map id, and records nothing. @@ -6829,7 +6843,6 @@ func _gen1_toggle_index(object_index: int) -> int: ## Whether the script holding the world is one that stops the map around it. -## ## `ScriptEvents` runs inside `HandleMap`, and `HandleMapObjects` runs on the ## same iteration, so a script standing in `WaitScript` or `WaitScriptMovement` ## leaves every object that is not frozen stepping: `FreezeAllOtherObjects` is @@ -8864,7 +8877,6 @@ func _object_landing_cells( ## Advances the movement templates whose source behavior is data-driven in this ## slice. Scripted movement is executed by the script runner, while followers ## advance after each successful player step. -## ## One decision per eligible object per call. A caller wanting the source's ## pacing uses advance_object_steps_pass(), which spends the durations. func advance_objects(random: RandomNumberGenerator) -> int: @@ -8956,7 +8968,6 @@ func _object_stays_on_screen(destination: Vector2i) -> bool: ## One hardware frame of the data-driven movement templates. -## ## An object spends a frame of an in-flight step, a frame of its wait, or takes ## a decision: STEP_TYPE_CONTINUE_WALK, _SLEEP and _FROM_MOVEMENT. The cell ## commits when the step starts, as InitStep does, and the offset trails. @@ -9058,7 +9069,6 @@ func advance_scripted_steps_pass() -> bool: ## Spends the frames a script is waiting on and resumes it the frame its wait ## ends, returning whatever that produced. -## ## The two waits are `ScriptEvents`'s own: SCRIPT_WAIT_MOVEMENT, which ends when ## the stream an `applymovement` started has been drawn, and the counted delay ## `pause`, `wait`, `deactivatefacing` and `showemote` spend. A host calls this @@ -10730,7 +10740,6 @@ func basement_key_request() -> Dictionary: ## `wItemEffectSucceeded` is set before the script is queued, and ## `.CheckCanUseSquirtbottle` only picks which half of it runs. So the refusal is ## the queued script's own `_SquirtbottleNothingText` rather than `.Oak`. -## ## The test is `GetFacingObject` and `cp SPRITEMOVEDATA_SUDOWOODO`, the same ## shape rock_smash_request() uses, behind the Route 36 map check. func squirtbottle_request() -> Dictionary: diff --git a/game/world/world_battle.gd b/game/world/world_battle.gd index 1f0d4c5b..68c20f09 100644 --- a/game/world/world_battle.gd +++ b/game/world/world_battle.gd @@ -37,7 +37,7 @@ static func prepare( battle_rules: Gen2Rules = null, player_id: int = -1, ) -> Dictionary: - if data == null or player_party == null: + if data == null: return _failure(&"missing_player_party") var raw_values: Variant = request.get("values", request) @@ -45,6 +45,9 @@ static func prepare( return _failure(&"invalid_battle_request") var values: Dictionary = (raw_values as Dictionary).duplicate(true) var kind: StringName = StringName(values.get("kind", &"")) + player_party = _tutor_stand_in(data, values, player_party) + if player_party == null: + return _failure(&"missing_player_party") if kind == &"battle_tower": for member: Gen2BattleMon in player_party.mons: member.restore_health() @@ -118,6 +121,14 @@ static func prepare( } +## Yellow's `.checkAnyPartyAlive` skips `AnyPartyAlive` for the Pikachu battle, +## fought before the player owns a Pokemon; a tutor sends nobody out. +static func _tutor_stand_in(data: GameData, values: Dictionary, party: Gen2Party) -> Gen2Party: + if bool(values.get("tutorial", false)) and (party == null or party.is_wiped()): + return fallback_party(data) + return party + + static func _recorded_party(data: GameData, values: Dictionary, tower: bool) -> Gen2Party: var members: Array = [] for raw_mon: Variant in values.get("enemy_party", []) as Array: diff --git a/game/world/world_party_host.gd b/game/world/world_party_host.gd index d2924d44..d6057c7c 100644 --- a/game/world/world_party_host.gd +++ b/game/world/world_party_host.gd @@ -1289,12 +1289,13 @@ static func capture_wild( return _failure(&"missing_capture_context", {}) ## The Dude's throw: `.catch_without_fail` in front of every multiplier, and ## `.return_from_capture`'s `cp BATTLETYPE_TUTORIAL / ret z` behind the line. - ## The ball is his and nothing is kept, so no transaction opens. + ## The ball is his and nothing is kept, so no transaction opens. Generation + ## 1's `.oldManBattle` ends the battle even on the throw Yellow refuses. if battle_type == Gen2Battle.BATTLETYPE_TUTORIAL: return { - "ok": true, "caught": true, "ball": ball, "wobbles": 3, + "ok": true, "caught": world.gen1_tutorial_ball_lands(), "ball": ball, "wobbles": 3, "catch_rate": 255, "species": wild.species, - "tutorial": true, "persistent": false, + "tutorial": true, "persistent": false, "ends_battle": true, } var opened: Dictionary = Gen2WorldTransaction.begin(world, save) if not bool(opened.get("ok", false)): diff --git a/game/world/world_screen.gd b/game/world/world_screen.gd index eeb36f43..d79dadfb 100644 --- a/game/world/world_screen.gd +++ b/game/world/world_screen.gd @@ -4863,8 +4863,20 @@ func preview_meet_visible_encounter(cell: Vector2i) -> bool: ## development Master Ball, starts an imported wild encounter, and leaves the ## production battle overlay on its throw message. ## `CatchTutorial`: the Dude's own fight, which answers itself. `Route29Tutorial1` -## loads the same `loadwildmon RATTATA, 5` in front of it. -func preview_catch_tutorial() -> void: +## loads the same `loadwildmon RATTATA, 5` in front of it. A Generation 1 cache +## throws the old man's, or Prof. Oak's for [param pikachu]. +func preview_catch_tutorial(pikachu: bool = false) -> void: + if _data != null and _data.generation == RomRegistry.GEN1: + var raw: int = Gen1Layout.BATTLE_TYPE_PIKACHU if pikachu else Gen1Layout.BATTLE_TYPE_OLD_MAN + var wilds: Dictionary = Gen1Layout.TUTOR_WILDS.get(_data.id, {}) + if not wilds.has(raw): + return + var values: Dictionary = { + "kind": &"wild", "pokemon": int(wilds[raw]), "level": Gen1Layout.TUTOR_WILD_LEVEL, + } + values.merge(Gen1Layout.battle_type_values(raw)) + _start_battle_request({"kind": &"battle_requested", "values": values}) + return _start_battle_request({ "kind": &"catch_tutorial_requested", "values": { diff --git a/tests/unit/test_gen1_layout.gd b/tests/unit/test_gen1_layout.gd index 6885fe8b..209b6992 100644 --- a/tests/unit/test_gen1_layout.gd +++ b/tests/unit/test_gen1_layout.gd @@ -592,3 +592,36 @@ func test_the_two_map_free_rods_carry_their_own_slots() -> void: assert_eq(good_rod.size(), 2) for slot: Dictionary in good_rod: assert_eq(int(slot["level"]), 10) + + +## `wBattleType` read across: the two tutors are Crystal's tutorial with the +## raw byte kept, the Safari is its own, and a value no script writes adds nothing. +func test_a_battle_type_byte_reads_across_onto_crystals() -> void: + var old_man: Dictionary = Gen1Layout.battle_type_values(Gen1Layout.BATTLE_TYPE_OLD_MAN) + assert_eq(int(old_man["battle_type"]), Gen2Battle.BATTLETYPE_TUTORIAL) + assert_true(bool(old_man["tutorial"])) + assert_eq(int(old_man["gen1_battle_type"]), Gen1Layout.BATTLE_TYPE_OLD_MAN) + var pikachu: Dictionary = Gen1Layout.battle_type_values(Gen1Layout.BATTLE_TYPE_PIKACHU) + assert_eq(int(pikachu["battle_type"]), Gen2Battle.BATTLETYPE_TUTORIAL) + assert_eq( + int(Gen1Layout.battle_type_values(Gen1Layout.BATTLE_TYPE_SAFARI)["battle_type"]), + Gen2Battle.BATTLETYPE_SAFARI + ) + assert_false(Gen1Layout.battle_type_values(Gen1Layout.BATTLE_TYPE_SAFARI).has("tutorial")) + assert_true(Gen1Layout.battle_type_values(0).is_empty()) + + +## `.oldManBattle`: only Yellow's old man can miss, and only under its event. +func test_the_tutors_ball_lands_unless_yellows_initial_training_stands() -> void: + var set_flag: Callable = func(_flag: int) -> bool: return true + var clear_flag: Callable = func(_flag: int) -> bool: return false + assert_true(Gen1Layout.tutorial_ball_lands(RomRegistry.RED, Gen1Layout.BATTLE_TYPE_OLD_MAN, set_flag)) + assert_true(Gen1Layout.tutorial_ball_lands(RomRegistry.YELLOW, Gen1Layout.BATTLE_TYPE_PIKACHU, set_flag)) + assert_true(Gen1Layout.tutorial_ball_lands(RomRegistry.YELLOW, Gen1Layout.BATTLE_TYPE_OLD_MAN, clear_flag)) + assert_false(Gen1Layout.tutorial_ball_lands(RomRegistry.YELLOW, Gen1Layout.BATTLE_TYPE_OLD_MAN, set_flag)) + + +## Red and Blue draw the player and the old man; Yellow adds Prof. Oak. +func test_yellow_alone_carries_prof_oaks_back_pic() -> void: + assert_eq(Gen1Layout.player_backpics(Gen1Layout.RED_BLUE), ["player", "old_man"]) + assert_eq(Gen1Layout.player_backpics(Gen1Layout.YELLOW), ["player", "old_man", "prof_oak"]) diff --git a/tests/unit/test_source_budget.gd b/tests/unit/test_source_budget.gd index 7b19ae4b..ecdb1410 100644 --- a/tests/unit/test_source_budget.gd +++ b/tests/unit/test_source_budget.gd @@ -21,7 +21,7 @@ const MAX_COMMENT_BLOCK: int = 8 ## the shared code, the largest of them 152 the region map, 150 the battle ## animation engine, 140 the transition, 131 the field moves, 130 the Pokedex, ## 108 the three PCs, 90 the Safari Zone, 52 the menus a row draws. -const MAX_COMMENT_LINES: int = 41986 +const MAX_COMMENT_LINES: int = 41981 ## What no comment block ever ends on. A pass that meets the ceiling above trims ## the second line of a two-line block and leaves the first mid-sentence, which diff --git a/tests/unit/test_world_api.gd b/tests/unit/test_world_api.gd index d7038baf..023b4170 100644 --- a/tests/unit/test_world_api.gd +++ b/tests/unit/test_world_api.gd @@ -10103,3 +10103,26 @@ func test_gen1_script_branches_read_pending_flags_and_scratch_in_each_choice() - assert_eq(steps[0]["no"], [{"type": &"text", "text": "NO"}]) assert_false(world.state.is_event_flag_active(683), "planning a choice writes no save flags") RomCache.clear(_gen1_directory()) + + +## `OverworldLoop` reads `wCurOpponent` after the script has returned, so a +## flag the row sets behind the store is set before the fight opens, and the +## `wBattleType` byte rides the request read across onto Crystal's numbering. +func test_gen1_a_rows_battle_stands_behind_the_rest_of_the_row() -> void: + var world: Gen2WorldAPI = _gen1_world(0, Vector2i(1, 2)) + world._gen1_steps = world._gen1_script_steps({"script": [ + {"op": "wild_battle", "species": 19, "level": 5, + "battle_type": Gen1Layout.BATTLE_TYPE_OLD_MAN}, + {"op": "flag", "flag": 47, "set": true}, + ]}) + var results: Array = world._gen1_result() + assert_true(world.state.is_event_flag_active(47), "the flag behind the store is set first") + var request: Dictionary = world.pending_runtime_request() + assert_eq(StringName(request.get("kind", &"")), &"battle_requested") + var values: Dictionary = request.get("values", {}) + assert_eq(int(values.get("battle_type", -1)), Gen2Battle.BATTLETYPE_TUTORIAL) + assert_true(bool(values.get("tutorial", false))) + assert_eq(int(values.get("gen1_battle_type", -1)), Gen1Layout.BATTLE_TYPE_OLD_MAN) + assert_true(world.gen1_tutorial_ball_lands()) + assert_eq(StringName((results[0] as Dictionary).get("status", &"")), &"waiting") + RomCache.clear(_gen1_directory()) diff --git a/tools/checks/gen1_battle.gd b/tools/checks/gen1_battle.gd index e3f21b49..cda39648 100644 --- a/tools/checks/gen1_battle.gd +++ b/tools/checks/gen1_battle.gd @@ -68,6 +68,7 @@ func _one_game() -> void: _teleport_ends_the_battle() _the_bag_in_a_fight() _a_safari_battle() + _the_tutor_throws() ## `AddPartyMon`'s four base moves and `WriteMonMoves` over them, for every @@ -533,3 +534,103 @@ func _safari_run_roll() -> void: ran[index] += 1 _r.check(ran[1] < ran[0] and ran[0] < ran[2], "the run rolls came out %s eating, plain and angry." % [ran]) + + +## The tutor on the real overlay, frame by frame, from the map script that +## starts him: Viridian City's catch training, Yellow's initial one that +## `ItemUseBall` refuses, and Prof. Oak's PIKACHU with no party at all. A row is +## the state to start on, the one left behind, the wild, and whether the ball lands. +const TUTOR_ROWS: Dictionary = { + &"red": [[1, 2, 13, true]], &"blue": [[1, 2, 13, true]], + &"yellow": [[3, 4, 19, true], [7, 8, 19, false]], +} +const TUTOR_GUARD_FRAMES: int = 6000 +const VIRIDIAN_CITY: int = 1 +const VIRIDIAN_BYTE: int = 4 +const PALLET_TOWN: int = 0 +const TUTOR_CELL := Vector2i(23, 10) +const PIKACHU_DEX: int = 25 +## `_ItemUseText001`, `text_low`, `_ItemUseText002`; `_ItemUseBallText05`'s +## `line` and `cont`; and `_ItemUseBallText04`. +const TUTOR_USED: String = "%s used\nPOKé BALL!" +const TUTOR_CAUGHT: String = "All right!\n%s was" + Gen2TextStream.SCROLL_BREAK + "caught!" +const TUTOR_BROKE_FREE: String = "Shoot! It was so\nclose too!" + + +func _the_tutor_throws() -> void: + for row: Array in TUTOR_ROWS[_r.game_id] as Array: + var screen: Gen2WorldScreen = _open_screen(VIRIDIAN_CITY, TUTOR_CELL, true) + var world: Gen2WorldAPI = screen.world() + var party_before: int = screen.active_save().party.size() + var balls_before: int = world.state.item_quantity(Gen1Layout.ITEM_POKE_BALL) + world.state.set_gen1_map_script(VIRIDIAN_BYTE, int(row[0])) + screen._show_script_results(world.dispatch_sight_events()) + var training: Dictionary = _drive_tutor(screen) + var name: String = String(_r.data.species(int(row[2])).get("name", "")) + _r.check(training["frames"] < TUTOR_GUARD_FRAMES, "the old man's battle never ended: %s" % [training]) + _r.check((training["messages"] as Array).has(TUTOR_USED % "OLD MAN"), "the old man threw nothing: %s" % [training]) + var landed: String = TUTOR_CAUGHT % name if bool(row[3]) else TUTOR_BROKE_FREE + _r.check((training["messages"] as Array).has(landed), "the old man's %s ball said %s" % [name, training]) + _r.check(world.state.gen1_map_script(VIRIDIAN_BYTE) == int(row[1]), + "the training left Viridian City on state %d." % world.state.gen1_map_script(VIRIDIAN_BYTE)) + _r.check(screen.active_save().party.size() == party_before + and world.state.item_quantity(Gen1Layout.ITEM_POKE_BALL) == balls_before, + "the old man's catch was kept.") + _r.note("gen1 tutor: the old man's %s in %d frames, %s" % [ + name, training["frames"], "caught" if bool(row[3]) else "broke free"]) + _close_screen(screen) + if not Gen1Layout.TUTOR_WILDS[_r.game_id].has(Gen1Layout.BATTLE_TYPE_PIKACHU): + return + var lab: Gen2WorldScreen = _open_screen(PALLET_TOWN, TUTOR_CELL, false) + lab.preview_catch_tutorial(true) + var oak: Dictionary = _drive_tutor(lab) + var pikachu: String = String(_r.data.species(PIKACHU_DEX).get("name", "")) + _r.check(oak["frames"] < TUTOR_GUARD_FRAMES, "Prof. Oak's battle never ended: %s" % [oak]) + _r.check((oak["messages"] as Array).has(TUTOR_USED % "PROF.OAK") + and (oak["messages"] as Array).has(TUTOR_CAUGHT % pikachu), "Prof. Oak's throw said %s" % [oak]) + _r.check(lab.active_save().party.is_empty(), "Prof. Oak's PIKACHU joined the party.") + _r.note("gen1 tutor: Prof. Oak's %s in %d frames" % [pikachu, oak["frames"]]) + _close_screen(lab) + + +func _open_screen(map: int, cell: Vector2i, party: bool) -> Gen2WorldScreen: + var screen: Gen2WorldScreen = (load("res://game/world/world_screen.tscn") as PackedScene).instantiate() + screen.map_group = 0 + screen.map_number = map + screen.start_cell = cell + screen.encounter_seed = 1 + screen.set_data(_r.data) + var save: Gen2SaveData = Gen2SaveStore.create_development_save(_r.data, 0) + if not party: + save.party = [] + screen.set_save(save) + (Engine.get_main_loop() as SceneTree).root.add_child(screen) + screen.set_process(false) + return screen + + +func _close_screen(screen: Gen2WorldScreen) -> void: + (Engine.get_main_loop() as SceneTree).root.remove_child(screen) + screen.free() + + +func _drive_tutor(screen: Gen2WorldScreen) -> Dictionary: + var messages: Array[String] = [] + var frames: int = 0 + var host: Gen2BattleScreen = null + while frames < TUTOR_GUARD_FRAMES: + frames += 1 + screen.advance_frame() + var current: Gen2BattleScreen = screen.get("_battle_host") + if host == null: + host = current + continue + if current == null: + break + var snapshot: Dictionary = host.battle_snapshot() + var line: String = String(snapshot.get("message", "")) + if messages.is_empty() or messages.back() != line: + messages.append(line) + if bool(snapshot.get("awaits_press", false)): + screen.press_button(PokeButton.A) + return {"frames": frames, "messages": messages} diff --git a/tools/checks/gen1_maps.gd b/tools/checks/gen1_maps.gd index 4773a99e..d64573a9 100644 --- a/tools/checks/gen1_maps.gd +++ b/tools/checks/gen1_maps.gd @@ -161,22 +161,22 @@ const SCRIPT_CENSUS: Dictionary = { "safari_balls": 1, "safari_steps": 1, "save_coord_index": 2, "map_load_bit": 2, "copy_name": 4, "set_fossil": 6, "party_menu": 1, "name_party_mon": 1, "mon_ot": 1, "name_mon": 1, "list_menu": 1, "emote": 1, "diploma": 1}, - &"yellow": {"rows": 373, "text": 610, "branch": 160, "choice": 34, "flag": 222, - "give_item": 41, "has_item": 15, "take_item": 9, "unknown": 4, "pokedex": 10, - "give_pokemon": 8, "saved_coord_index": 2, "badges_byte": 1, "walk": 23, - "set_map_script": 99, "npc_movement_script": 2, "toggle_object": 22, - "trainer_battle_object": 27, "random": 5, "facing": 12, "player_in_array": 1, - "name_species": 7, "flag_test": 6, "pick_up_item": 109, "scratch": 29, - "name_badge": 7, "player_facing": 16, "heal_party": 2, "emote": 8, "dex_rating": 2, - "dex_count": 6, "map_text": 24, "trade": 7, "name_item": 7, "oaks_aide": 3, + &"yellow": {"rows": 374, "text": 613, "branch": 160, "choice": 34, "flag": 224, + "give_item": 41, "has_item": 15, "take_item": 10, "unknown": 3, "pokedex": 10, + "give_pokemon": 9, "saved_coord_index": 2, "player_facing": 16, "emote": 8, + "badges_byte": 1, "walk": 23, "set_map_script": 100, "npc_movement_script": 2, + "toggle_object": 22, "trainer_battle_object": 27, "random": 5, "facing": 12, + "player_in_array": 1, "name_species": 8, "flag_test": 6, "pick_up_item": 109, + "scratch": 29, "name_badge": 7, "heal_party": 2, "dex_rating": 2, "dex_count": 6, + "set_starter": 1, "map_text": 24, "trade": 7, "name_item": 7, "oaks_aide": 3, "player_coord": 4, "money_box": 6, "has_money": 5, "spend_money": 4, "menu": 3, "menu_cancel": 3, "menu_row": 1, "guard_drink": 4, "day_care": 1, "random_bit": 2, - "volatile": 2, "filtered_bag": 2, "menu_item": 4, "elevator": 3, "coin_box": 2, - "has_coins": 4, "add_coins": 4, "replace_block": 1, "trainer_battle": 1, - "safari_balls": 3, "safari_steps": 3, "safari_admission": 2, "save_coord_index": 2, - "map_load_bit": 2, "talking_to": 14, "volatile_test": 6, "copy_name": 4, - "set_fossil": 6, "party_menu": 1, "name_party_mon": 1, "mon_ot": 1, "name_mon": 1, - "list_menu": 1, "diploma": 1}, + "volatile": 2, "filtered_bag": 2, "menu_item": 4, "elevator": 3, "diploma": 1, + "coin_box": 2, "has_coins": 4, "add_coins": 4, "replace_block": 1, + "trainer_battle": 1, "safari_balls": 3, "safari_steps": 3, "safari_admission": 2, + "save_coord_index": 2, "map_load_bit": 2, "talking_to": 14, "volatile_test": 6, + "copy_name": 4, "set_fossil": 6, "party_menu": 1, "name_party_mon": 1, "mon_ot": 1, + "name_mon": 1, "list_menu": 1}, } ## `SilphCo11FPorygonText` is a `call DisplayPokedex` the disassembly marks ## unreferenced. The `trade` rows are the eight `predef DoInGameTradeDialogue` @@ -255,21 +255,21 @@ const STATE_CENSUS: Dictionary = { "object_coord_move": 1, "warp_to": 1, "set_last_map": 1, "safari_balls": 1, "set_riding": 1, "replace_block": 24, "volatile": 1, "volatile_test": 1, "coord_lookup": 3, "trainer_battle_object": 1}, - &"yellow": {"tables": 98, "states": 408, "read": 223, "branch": 123, "player_coord": 79, - "flag": 301, "player_facing": 63, "set_map_script": 369, "save_coord_index": 15, - "map_text": 174, "object_position": 12, "toggle_object": 181, "object_facing": 85, - "set_player_coord": 1, "object_path": 2, "movement_running": 81, "wild_battle": 6, + &"yellow": {"tables": 98, "states": 411, "read": 226, "branch": 123, "player_coord": 86, + "flag": 306, "player_facing": 63, "set_map_script": 380, "save_coord_index": 15, + "map_text": 181, "object_position": 28, "toggle_object": 195, "object_facing": 94, + "set_player_coord": 1, "object_path": 2, "movement_running": 83, "wild_battle": 6, "npc_movement_script": 1, "movement_script_running": 4, "flag_test": 20, - "walk": 61, "badges_byte": 6, "object_move": 93, "player_in_array": 38, + "walk": 61, "badges_byte": 6, "object_move": 102, "player_in_array": 38, "riding": 4, "coord_index": 18, "trainer_battle": 17, "battle_outcome": 38, "object_stay": 17, "facing": 4, "has_item": 4, "emote": 2, "saved_coord_index": 18, "starter": 1, "set_starter": 4, "badge_guards": 1, "name_species": 1, - "heal_party": 3, "give_item": 9, "arrow_movement": 3, "guard_drink": 4, - "volatile_test": 3, "volatile": 8, "random": 1, "boulder_on": 3, + "heal_party": 3, "scratch": 16, "give_item": 9, "arrow_movement": 3, + "guard_drink": 4, "volatile_test": 3, "volatile": 8, "random": 1, "boulder_on": 3, "map_load_bit": 10, "hall_of_fame": 1, "set_blackout_map": 1, "save_game": 1, "reset_game": 1, "warp_to": 1, "set_last_map": 1, "safari_balls": 1, - "set_riding": 1, "scratch_test": 3, "scratch": 8, "replace_block": 36, - "coord_lookup": 3, "trainer_battle_object": 2}, + "set_riding": 1, "scratch_test": 3, "replace_block": 36, "coord_lookup": 3, + "trainer_battle_object": 2}, } ## The pin on which way a `wCurrentMenuItem` branch reads. diff --git a/tools/checks/gen1_pics.gd b/tools/checks/gen1_pics.gd index 9213eba0..5e22858d 100644 --- a/tools/checks/gen1_pics.gd +++ b/tools/checks/gen1_pics.gd @@ -18,7 +18,7 @@ const TRAINER_SIDE: int = 7 ## SHA-1 of each atlas's own index buffer. Red and Blue share every picture, ## and Yellow redrew all 151 front pics, six trainer ones (Brock, Misty, Erika -## and the rival's three) and `RedPicFront`, but no back pic at all. +## and the rival's three) and `RedPicFront`, and added `ProfOakPicBack` alone. const DIGESTS: Dictionary = { &"red": { "front": "672ba02e2d98a787fb619e11e77fba45a379a47b", @@ -38,7 +38,7 @@ const DIGESTS: Dictionary = { "front": "d6e50eed9888dbe7787b1a1952403eab5bd133b6", "back": "a8976278a8c8294f285a25a993a44f2f6012b127", "trainers": "6b0fe80efffb9a8223a9646b1df42cfa592fd0f4", - "player_back": "6c2830e479cf877b20bd10f6c94324bb5c3e425d", + "player_back": "161795669025786e3c4d004a400325e05b53d57b", "player_front": "070dcf72706fc8ab991158a8bd32beae0c7a0d36", }, } @@ -106,7 +106,7 @@ func _one_game() -> void: _atlas("front", SPECIES_COUNT, FRONT_SIDES[FRONT_SIDES.size() - 1]) _atlas("back", SPECIES_COUNT, BACK_SIDE) _atlas("trainers", TRAINER_COUNT, TRAINER_SIDE) - _atlas("player_back", Gen1Layout.PLAYER_BACKPICS.size(), BACK_SIDE) + _atlas("player_back", _backpics().size(), BACK_SIDE) _atlas("player_front", PLAYER_FRONTPICS, TRAINER_SIDE) _species_pics() _trainer_pics() @@ -181,13 +181,23 @@ func _trainer_pics() -> void: ) +func _backpics() -> Array[String]: + return Gen1Layout.player_backpics(Gen1Layout.for_id(_r.game_id)) + + func _player_pics() -> void: + var backpics: Array[String] = _backpics() + _r.check( + backpics.size() == (3 if _r.game_id == RomRegistry.YELLOW else 2), + "the cartridge draws %d back pics." % backpics.size() + ) var drawn: Array[PackedByteArray] = [] - for slot: int in Gen1Layout.PLAYER_BACKPICS.size(): + for slot: int in backpics.size(): var cell: Dictionary = _cell("player_back", slot) - _r.check(_ink(cell), "the %s back pic is blank." % Gen1Layout.PLAYER_BACKPICS[slot]) - drawn.append(cell.get("indices", PackedByteArray())) - _r.check(drawn[0] != drawn[1], "the player and the old man share a back pic.") + _r.check(_ink(cell), "the %s back pic is blank." % backpics[slot]) + var indices: PackedByteArray = cell.get("indices", PackedByteArray()) + _r.check(not drawn.has(indices), "the %s back pic repeats another." % backpics[slot]) + drawn.append(indices) var front: Dictionary = _cell("player_front", 0) _r.check(_ink(front), "RedPicFront is blank.") _r.check( @@ -245,7 +255,7 @@ func _battle_page() -> void: _r.check(drawn != blank, "battle tile $%02X is blank." % tile) var cropped: Array[int] = [] - for slot: int in SPECIES_COUNT + Gen1Layout.PLAYER_BACKPICS.size(): + for slot: int in SPECIES_COUNT + _backpics().size(): var name: String = "back" if slot < SPECIES_COUNT else "player_back" if _draws_outside_crop(name, slot if slot < SPECIES_COUNT else slot - SPECIES_COUNT): cropped.append(slot + 1) diff --git a/tools/checks/gen1_walk.gd b/tools/checks/gen1_walk.gd index db02829a..0756eb52 100644 --- a/tools/checks/gen1_walk.gd +++ b/tools/checks/gen1_walk.gd @@ -495,6 +495,7 @@ func _one_game() -> void: _check_the_saffron_guard() _check_an_arrow_tile() _check_a_scripted_wild_battle() + _check_the_catch_training() if _r.game_id != RomRegistry.YELLOW: _check_the_opening_walk() _check_the_route_23_guards() @@ -2708,6 +2709,46 @@ func _check_a_scripted_wild_battle() -> void: _r.note("gen1 walk ROUTE_12's Snorlax fought at level %d" % SNORLAX_LEVEL) +## `ViridianCityOldManStartCatchTrainingScript`'s `wBattleType` as the Dude's +## tutorial, and `wCurOpponent` read behind the whole state: Yellow's initial +## training sets EVENT_INITIAL_CATCH_TRAINING after the store and breaks out on it. +const VIRIDIAN_BYTE: int = 4 +## Each row: the state to start on, the one it moves to, and whether the ball lands. +const CATCH_TRAINING_STATES: Dictionary = { + &"red": [[1, 2, true]], &"blue": [[1, 2, true]], &"yellow": [[3, 4, true], [7, 8, false]], +} + + +func _check_the_catch_training() -> void: + for row: Array in CATCH_TRAINING_STATES[_r.game_id] as Array: + var world: Gen2WorldAPI = _r.open_world(0, VIRIDIAN_CITY, Vector2i(23, 10)) + if world == null: + return + world.state.set_gen1_map_script(VIRIDIAN_BYTE, int(row[0])) + var results: Array = world.dispatch_sight_events() + var request: Dictionary = world.pending_runtime_request() + var values: Dictionary = request.get("values", {}) as Dictionary + if not _r.check( + StringName(request.get("kind", &"")) == &"battle_requested" + and bool(values.get("tutorial", false)) + and int(values.get("battle_type", -1)) == Gen2Battle.BATTLETYPE_TUTORIAL + and int(values.get("gen1_battle_type", -1)) == Gen1Layout.BATTLE_TYPE_OLD_MAN, + "state %d asked for %s / %s." % [row[0], request, results] + ): + return + _r.check(world.state.gen1_map_script(VIRIDIAN_BYTE) == int(row[1]), + "the state behind the old man's battle is %d." % world.state.gen1_map_script(VIRIDIAN_BYTE)) + _r.check(world.gen1_tutorial_ball_lands() == bool(row[2]), + "the old man's ball from state %d %s." % [ + row[0], "landed" if world.gen1_tutorial_ball_lands() else "broke out"]) + world.complete_runtime_request({ + "ok": true, "outcome": Gen2WorldBattleAdapter.OUTCOME_CAUGHT, + }) + _r.check(world.pending_runtime_request().is_empty(), "the training left a request standing.") + _r.note("gen1 walk VIRIDIAN_CITY's catch training from %d states" % ( + CATCH_TRAINING_STATES[_r.game_id] as Array).size()) + + func _saffron_drink_flag() -> int: return Gen1Layout.engine_flag_base("status_flags_1") + SAFFRON_DRINK_BIT diff --git a/tools/preview_world.gd b/tools/preview_world.gd index c157c321..54822194 100644 --- a/tools/preview_world.gd +++ b/tools/preview_world.gd @@ -19,7 +19,7 @@ const KIND_HELP: Dictionary = { &"battle": "frames, 0: the wild fight preview_battle_request starts, settled past its transition. 1 opens the bag over it and 2 plays the POKé FLUTE from it", &"battle_caught": "frames: the same fight against a species the dex already holds", &"safari": "frames, 0: the Safari game's own battle menu over a Safari Zone map. 1 draws PrintSafariZoneSteps' window on the START menu instead", - &"catch_tutorial": "frames: the Dude's own fight, which answers itself, that many frames in", + &"catch_tutorial": "frames: the Dude's own fight, which answers itself, that many frames in. A Generation 1 cartridge throws the old man's ball, or Prof. Oak's with a second number of 1", &"catch_dex": "none: NewPokedexEntry's page, over the fight the catch that opened it is still in", &"cut": "cell: OWCutAnimation's two halves and the jump shadow", &"fly": "none: FlyFromAnim 80 frames in, with HideSprites' empty OAM behind it", @@ -664,10 +664,19 @@ func _open_battle_bag(frames: int, flute: bool) -> void: ## `CatchTutorial`, played by `DudeAutoInputs` rather than by anybody. The first ## number is how many frames in to photograph: nothing here presses anything. +## A second number of 1 is Yellow's Prof. Oak. func _stage_catch_tutorial() -> void: - _screen.preview_catch_tutorial() + _screen.preview_catch_tutorial(_cell.y == 1) _screen.settle_battle_transition() - _screen.advance_frames(maxi(_cell.x, STAGED_FRAMES)) + if _generation() != RomRegistry.GEN1: + _screen.advance_frames(maxi(_cell.x, STAGED_FRAMES)) + return + ## The tutor's boxes wait on the player, so A is pressed wherever one owes it. + var host: Gen2BattleScreen = _screen.get("_battle_host") + for _frame: int in maxi(_cell.x, STAGED_FRAMES): + if host != null and bool(host.battle_snapshot().get("awaits_press", false)): + _screen.press_button(PokeButton.A) + _screen.advance_frame() ## `DoBattleTransition` over the map it runs on. The first of the two numbers is how diff --git a/tools/preview_world_story.gd b/tools/preview_world_story.gd index 2fbb5527..58610684 100644 --- a/tools/preview_world_story.gd +++ b/tools/preview_world_story.gd @@ -8639,6 +8639,9 @@ func _request_party_host(world: Gen2WorldAPI, _request: Dictionary, state: Dicti ## answers the request itself instead of opening a battle screen, so it credits ## the same accounts that screen would have. func _request_battle(world: Gen2WorldAPI, request: Dictionary, state: Dictionary) -> Array: + ## A Generation 1 tutor rides a `battle_requested` the map script wrote. + if bool((request.get("values", {}) as Dictionary).get("tutorial", false)): + return _request_catch_tutorial(world, request, state) var data: GameData = state["data"] var save: Gen2SaveData = state["save"] var player_party: Gen2Party = ( @@ -9302,7 +9305,10 @@ const GEN1_MT_MOON_NERD_CELL := Vector2i(13, 8) const GEN1_MT_MOON_BELOW_DOME := Vector2i(12, 7) const GEN1_MT_MOON_B2F_EXIT_LADDER := Vector2i(5, 7) const GEN1_MT_MOON_B1F_EXIT := Vector2i(27, 3) -const GEN1_EVENT_GOT_DOME_FOSSIL: int = 0x570 + 14 +## Yellow's Mt. Moon run puts it right behind the 1F trainers. +const GEN1_EVENT_GOT_DOME_FOSSIL: Dictionary = { + &"red": 0x570 + 14, &"blue": 0x570 + 14, &"yellow": 0x570 + 8, +} const GEN1_ROUTE_24: int = 35 const GEN1_ROUTE_25: int = 36 const GEN1_BILLS_HOUSE: int = 88 @@ -9311,6 +9317,7 @@ const GEN1_CERULEAN_BRIDGE_FOOT := Vector2i(20, 6) const GEN1_CERULEAN_GYM_DOOR := Vector2i(30, 19) const GEN1_BELOW_BILL_POKEMON := Vector2i(6, 6) const GEN1_BILLS_PC_CELL := Vector2i(1, 5) +const GEN1_BELOW_DAMIAN := Vector2i(6, 6) const GEN1_BELOW_BILL := Vector2i(4, 5) const GEN1_MISTY := Vector2i(4, 2) const GEN1_EVENT_BEAT_CERULEAN_RIVAL: int = 152 @@ -9360,7 +9367,11 @@ const GEN1_PALLET_NORTH_PATH: Dictionary = { } const GEN1_PALLET_LAB_DOOR := Vector2i(12, 11) const GEN1_LAB_DOOR := Vector2i(4, 11) -const GEN1_LAB_BELOW_CHARMANDER := Vector2i(6, 4) +## The cell under the ball the walk takes and the dex number it gives: Yellow's +## one EEVEE ball sends the rival for it and walks the player to Oak's PIKACHU. +const GEN1_LAB_STARTER_BALLS: Dictionary = { + &"red": [Vector2i(6, 4), 4], &"blue": [Vector2i(6, 4), 4], &"yellow": [Vector2i(7, 4), 25], +} const GEN1_LAB_BELOW_OAK := Vector2i(5, 3) const GEN1_VIRIDIAN_MART_DOOR := Vector2i(29, 19) const GEN1_MART_DOOR := Vector2i(3, 7) @@ -9375,7 +9386,6 @@ const GEN1_EVENT_OAK_GOT_PARCEL: int = 56 const GEN1_EVENT_GOT_OAKS_PARCEL: int = 57 const GEN1_EVENT_GOT_TM34: int = 118 const GEN1_EVENT_BEAT_BROCK: int = 119 -const GEN1_CHARMANDER_DEX: int = 4 const GEN1_OAKS_PARCEL: int = 0x46 const GEN1_TM34: int = 0xEA const GEN1_BIT_BOULDERBADGE: int = 0 @@ -9689,17 +9699,18 @@ func _gen1_lab_leg( world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, path: Array ) -> Dictionary: + var ball: Array = GEN1_LAB_STARTER_BALLS[data.id] var starter: Dictionary = _gen1_talk( - world, GEN1_LAB_BELOW_CHARMANDER, Gen2WorldSprite.FACING_UP, save, random, data + world, ball[0], Gen2WorldSprite.FACING_UP, save, random, data ) - var stepped: Dictionary = _gen1_step(path, "oaks_lab_charmander", world, starter, { + var stepped: Dictionary = _gen1_step(path, "oaks_lab_starter", world, starter, { "party": _party_species(save)}) if not bool(stepped["ok"]): return stepped - stepped = _gen1_flag_leg(path, "oaks_lab_charmander", world, GEN1_EVENT_GOT_STARTER, "EVENT_GOT_STARTER") + stepped = _gen1_flag_leg(path, "oaks_lab_starter", world, GEN1_EVENT_GOT_STARTER, "EVENT_GOT_STARTER") if not bool(stepped["ok"]): return stepped - if save.party.is_empty() or int(save.party[0].species) != GEN1_CHARMANDER_DEX: + if save.party.is_empty() or int(save.party[0].species) != int(ball[1]): return {"ok": false, "path": path, "reason": "the ball gave %s" % [_party_species(save)]} var leaving: Dictionary = _gen1_warp_walk(world, GEN1_LAB_DOOR, save, random, data) stepped = _gen1_step(path, "oaks_lab_rival_fight", world, leaving, {"party": _party_species(save)}) @@ -9901,7 +9912,9 @@ func _gen1_mt_moon_leg( "items": _named_items(data, world.state.items())}) if not bool(stepped["ok"]): return stepped - stepped = _gen1_flag_leg(path, "mt_moon_dome_fossil", world, GEN1_EVENT_GOT_DOME_FOSSIL, "EVENT_GOT_DOME_FOSSIL") + stepped = _gen1_flag_leg( + path, "mt_moon_dome_fossil", world, GEN1_EVENT_GOT_DOME_FOSSIL[data.id], "EVENT_GOT_DOME_FOSSIL" + ) if not bool(stepped["ok"]): return stepped return _gen1_warp_legs(path, world, save, random, data, [ @@ -9928,6 +9941,21 @@ func _gen1_bills_house_leg( ) -> Dictionary: var stepped: Dictionary = _gen1_warp_legs(path, world, save, random, data, [ ["north", GEN1_ROUTE_24, "cerulean_to_route_24"], + ]) + if not bool(stepped["ok"]): + return stepped + ## Yellow's PIKACHU learns no HM01; Damian's CHARMANDER cuts the gym's tree. + if data.id == RomRegistry.YELLOW: + var damian: Dictionary = _gen1_talk( + world, GEN1_BELOW_DAMIAN, Gen2WorldSprite.FACING_UP, save, random, data + ) + stepped = _gen1_step(path, "route_24_damian", world, damian, {"party": _party_species(save)}) + if not bool(stepped["ok"]): + return stepped + if _party_species(save).size() != 2: + return {"ok": false, "path": path, "reason": "route_24_damian: Damian left the party %s" % [ + _party_species(save)]} + stepped = _gen1_warp_legs(path, world, save, random, data, [ ["east", GEN1_ROUTE_25, "route_24_to_route_25"], [GEN1_BILLS_HOUSE, "route_25_to_bills_house"], ]) diff --git a/tools/validate.gd b/tools/validate.gd index dfedfc55..c59b59b4 100644 --- a/tools/validate.gd +++ b/tools/validate.gd @@ -52,15 +52,25 @@ const GROUPS: Dictionary = { } +var _names: PackedStringArray = [] + + func _initialize() -> void: var topics: PackedStringArray = _available() - var names: PackedStringArray = _requested(topics) - if names.is_empty(): + _names = _requested(topics) + if _names.is_empty(): print("Topics: %s" % ", ".join(topics)) print("Groups: %s, all" % ", ".join(PackedStringArray(GROUPS.keys()))) quit(2) - return + +## On the first frame rather than in `_initialize`: the root is not inside the +## tree until the loop starts, so a screen a topic adds there is never ready. +func _process(_delta: float) -> bool: + if _names.is_empty(): + return true + var names: PackedStringArray = _names + _names = [] var failed: PackedStringArray = [] for name: String in names: var run := CheckRun.new() @@ -87,6 +97,7 @@ func _initialize() -> void: if not failed.is_empty(): printerr("FAILED: %s" % ", ".join(failed)) quit(1 if not failed.is_empty() else 0) + return true ## Every topic on disk, so a new check file needs no registration. From 30b37dba76af7f5792ebd0300bf6f8bff8b6b2fa Mon Sep 17 00:00:00 2001 From: Decrypt <90518536+Decryptu@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:40:20 +0200 Subject: [PATCH 2/8] Walk Red, Blue and Yellow on from Lt. Surge to Erika Back up the Underground Path, Route 9 behind its tree, Rock Tunnel's four ladders, Lavender Town, Route 8's Underground Path to Route 7, Celadon City and the gym: four badges, 55 fights, 103 steps on all three cartridges. Vermilion's gym tree stands again on the way out, since `ReplaceTreeTileBlock` edits the tilemap alone, and is cut from inside the fence; Rock Tunnel's route is the one `TilePairCollisionsLand`'s CAVERN rows leave. --- README.md | 2 +- tools/preview_world_story.gd | 154 ++++++++++++++++++++++++++++++++--- 2 files changed, 144 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4b10236e..9d88126f 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ The rest are previews and dumps, each driving a real screen or table: ```bash # the full walked route: Johto, the Hall of Fame, every Kanto gym, and Red godot --headless --path . -s res://tools/preview_world_story.gd -- crystal 24 7 2 2 1 none home story -# Red, Blue or Yellow from the bedroom to Lt. Surge +# Red, Blue or Yellow from the bedroom to Erika godot --headless --path . -s res://tools/preview_world_story.gd -- red 0 38 3 6 1 none home story ``` diff --git a/tools/preview_world_story.gd b/tools/preview_world_story.gd index 58610684..6aa9e326 100644 --- a/tools/preview_world_story.gd +++ b/tools/preview_world_story.gd @@ -9359,6 +9359,40 @@ const GEN1_EVENT_GOT_TM24: int = 358 const GEN1_EVENT_BEAT_LT_SURGE: int = 359 const GEN1_EVENT_2ND_LOCK_OPENED: int = 352 const GEN1_BIT_THUNDERBADGE: int = 2 +const GEN1_ROUTE_9: int = 20 +const GEN1_ROUTE_10: int = 21 +const GEN1_ROCK_TUNNEL_1F: int = 82 +const GEN1_ROCK_TUNNEL_B1F: int = 232 +const GEN1_LAVENDER_TOWN: int = 4 +const GEN1_ROUTE_8: int = 19 +const GEN1_ROUTE_7: int = 18 +const GEN1_UNDERGROUND_PATH_ROUTE_8: int = 80 +const GEN1_UNDERGROUND_PATH_ROUTE_7: int = 77 +const GEN1_UNDERGROUND_PATH_WEST_EAST: int = 121 +const GEN1_CELADON_CITY: int = 6 +const GEN1_CELADON_GYM: int = 134 +## The tree behind Route 9's west edge, and the one `CeladonGym`'s corridor ends on. +const GEN1_ROUTE_9_TREE_APPROACH := Vector2i(4, 8) +## `ReplaceTreeTileBlock` edits the tilemap alone, so the gym's tree stands +## again on the way out and is cut from inside the fence. +const GEN1_VERMILION_TREE_INSIDE := Vector2i(15, 19) +const GEN1_CELADON_GYM_TREE_APPROACH := Vector2i(5, 8) +## The tree at (35, 32), which seals the gym's own corner of Celadon City. +const GEN1_CELADON_TREE_APPROACH := Vector2i(35, 31) +## Rock Tunnel's four ladders in walking order, each a floor and its cell, and +## the south exit: `TilePairCollisionsLand`'s CAVERN rows are what make the +## floors a maze rather than open rock. +const GEN1_ROCK_TUNNEL_LADDERS: Array = [ + [GEN1_ROCK_TUNNEL_B1F, Vector2i(37, 3)], [GEN1_ROCK_TUNNEL_1F, Vector2i(27, 3)], + [GEN1_ROCK_TUNNEL_B1F, Vector2i(17, 11)], [GEN1_ROCK_TUNNEL_1F, Vector2i(3, 3)], +] +const GEN1_ROCK_TUNNEL_SOUTH_EXIT := Vector2i(15, 33) +const GEN1_ROUTE_8_UNDERGROUND := Vector2i(13, 3) +const GEN1_CELADON_GYM_DOOR := Vector2i(12, 27) +const GEN1_ERIKA := Vector2i(4, 3) +const GEN1_EVENT_GOT_TM21: int = 424 +const GEN1_EVENT_BEAT_ERIKA: int = 425 +const GEN1_BIT_RAINBOWBADGE: int = 3 const GEN1_BEDROOM_STAIRS := Vector2i(7, 1) const GEN1_HOUSE_DOOR := Vector2i(2, 7) ## `PalletTownDefaultScript` stops the player at `wYCoord == 1`, and Yellow's at 0. @@ -9438,7 +9472,7 @@ func _gen1_story_path(data: GameData) -> Dictionary: _gen1_pokedex_leg, _gen1_viridian_forest_leg, _gen1_pewter_gym_leg, _gen1_mt_moon_leg, _gen1_cerulean_rival_leg, _gen1_bills_house_leg, _gen1_cerulean_gym_leg, _gen1_cerulean_thief_leg, _gen1_ss_anne_leg, - _gen1_vermilion_gym_leg, + _gen1_vermilion_gym_leg, _gen1_rock_tunnel_leg, _gen1_celadon_gym_leg, ] for leg: Callable in legs: var walked: Dictionary = leg.call(world, save, random, data, path) @@ -10097,16 +10131,9 @@ func _gen1_vermilion_gym_leg( "moves": _party_moves(save)}) if not bool(stepped["ok"]): return stepped - var walked: Dictionary = _gen1_walk(world, GEN1_VERMILION_TREE_APPROACH, save, random, data) - if bool(walked.get("ok", false)): - world.player_facing = Gen2WorldSprite.FACING_RIGHT - var request: Dictionary = world.cut_request() - if not bool(request.get("ok", false)): - walked = {"ok": false, "reason": "cut refused: %s" % request.get("reason", "")} - else: - var applied: Dictionary = world.complete_cut() - walked = {"ok": bool(applied.get("ok", false)), "reason": applied.get("reason", "")} - stepped = _gen1_step(path, "vermilion_cut_tree", world, walked) + stepped = _gen1_step(path, "vermilion_cut_tree", world, _gen1_cut( + world, GEN1_VERMILION_TREE_APPROACH, Gen2WorldSprite.FACING_RIGHT, save, random, data + )) if not bool(stepped["ok"]): return stepped stepped = _gen1_warp_legs(path, world, save, random, data, [ @@ -10151,3 +10178,108 @@ func _gen1_vermilion_gym_leg( if not world.state.is_engine_flag_active(Gen2WorldState.gen1_badge_flag(GEN1_BIT_THUNDERBADGE)): return {"ok": false, "path": path, "reason": "vermilion_gym_lt_surge: THUNDERBADGE is clear"} return _gen1_warp_legs(path, world, save, random, data, [[GEN1_VERMILION_CITY, "vermilion_gym_exit"]]) + + +## `UsedCut` on the tree in front of [param cell], faced [param facing]. +func _gen1_cut( + world: Gen2WorldAPI, cell: Vector2i, facing: int, save: Gen2SaveData, + random: RandomNumberGenerator, data: GameData +) -> Dictionary: + var walked: Dictionary = _gen1_walk(world, cell, save, random, data) + if not bool(walked.get("ok", false)): + return walked + world.player_facing = facing + var request: Dictionary = world.cut_request() + if not bool(request.get("ok", false)): + return {"ok": false, "reason": "cut refused: %s" % request.get("reason", ""), "runs": walked["runs"]} + var applied: Dictionary = world.complete_cut() + return {"ok": bool(applied.get("ok", false)), "reason": applied.get("reason", ""), "runs": walked["runs"]} + + +## Vermilion back up the Underground Path, Route 9 behind its tree, Rock +## Tunnel's two ladders and out to Lavender Town. +func _gen1_rock_tunnel_leg( + world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, + path: Array +) -> Dictionary: + var stepped: Dictionary = _gen1_step(path, "vermilion_cut_tree_again", world, _gen1_cut( + world, GEN1_VERMILION_TREE_INSIDE, Gen2WorldSprite.FACING_UP, save, random, data + )) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + ["north", GEN1_ROUTE_6, "vermilion_to_route_6"], + [GEN1_UNDERGROUND_PATH_ROUTE_6, "route_6_to_underground"], + [GEN1_UNDERGROUND_PATH_NORTH_SOUTH, "underground_south_entrance"], + [GEN1_UNDERGROUND_PATH_ROUTE_5, "underground_north_exit"], + [GEN1_ROUTE_5, "underground_to_route_5"], + ["north", GEN1_CERULEAN_CITY, "route_5_to_cerulean"], + ["east", GEN1_ROUTE_9, "cerulean_to_route_9"], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_step(path, "route_9_cut_tree", world, _gen1_cut( + world, GEN1_ROUTE_9_TREE_APPROACH, Gen2WorldSprite.FACING_RIGHT, save, random, data + )) + if not bool(stepped["ok"]): + return stepped + var legs: Array = [ + ["east", GEN1_ROUTE_10, "route_9_to_route_10"], + [GEN1_ROCK_TUNNEL_1F, "route_10_to_rock_tunnel"], + ] + for ladder: int in GEN1_ROCK_TUNNEL_LADDERS.size(): + var row: Array = GEN1_ROCK_TUNNEL_LADDERS[ladder] + legs.append([int(row[0]), "rock_tunnel_ladder_%d" % (ladder + 1), row[1]]) + legs.append_array([ + [GEN1_ROUTE_10, "rock_tunnel_to_route_10", GEN1_ROCK_TUNNEL_SOUTH_EXIT], + ["south", GEN1_LAVENDER_TOWN, "route_10_to_lavender"], + ]) + return _gen1_warp_legs(path, world, save, random, data, legs) + + +## Route 8's Underground Path to Celadon City, the gym's own tree and Erika. +func _gen1_celadon_gym_leg( + world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, + path: Array +) -> Dictionary: + var stepped: Dictionary = _gen1_warp_legs(path, world, save, random, data, [ + ["west", GEN1_ROUTE_8, "lavender_to_route_8"], + [GEN1_UNDERGROUND_PATH_ROUTE_8, "route_8_to_underground", GEN1_ROUTE_8_UNDERGROUND], + [GEN1_UNDERGROUND_PATH_WEST_EAST, "underground_east_entrance"], + [GEN1_UNDERGROUND_PATH_ROUTE_7, "underground_west_exit"], + [GEN1_ROUTE_7, "underground_to_route_7"], + ["west", GEN1_CELADON_CITY, "route_7_to_celadon"], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_step(path, "celadon_cut_tree", world, _gen1_cut( + world, GEN1_CELADON_TREE_APPROACH, Gen2WorldSprite.FACING_DOWN, save, random, data + )) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_CELADON_GYM, "celadon_gym_entry", GEN1_CELADON_GYM_DOOR], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_step(path, "celadon_gym_cut_tree", world, _gen1_cut( + world, GEN1_CELADON_GYM_TREE_APPROACH, Gen2WorldSprite.FACING_UP, save, random, data + )) + if not bool(stepped["ok"]): + return stepped + var erika: Dictionary = _gen1_talk_to(world, GEN1_ERIKA, save, random, data) + stepped = _gen1_step(path, "celadon_gym_erika", world, erika, { + "party": _party_species(save), "items": _named_items(data, world.state.items()), + "badges": world.state.badge_count(), + }) + if not bool(stepped["ok"]): + return stepped + for flag: Array in [ + [GEN1_EVENT_BEAT_ERIKA, "EVENT_BEAT_ERIKA"], [GEN1_EVENT_GOT_TM21, "EVENT_GOT_TM21"], + ]: + stepped = _gen1_flag_leg(path, "celadon_gym_erika", world, int(flag[0]), String(flag[1])) + if not bool(stepped["ok"]): + return stepped + if not world.state.is_engine_flag_active(Gen2WorldState.gen1_badge_flag(GEN1_BIT_RAINBOWBADGE)): + return {"ok": false, "path": path, "reason": "celadon_gym_erika: RAINBOWBADGE is clear"} + return _gen1_warp_legs(path, world, save, random, data, [[GEN1_CELADON_CITY, "celadon_gym_exit"]]) From 2263bebc4c8573ed5c0a3cd66e6594e4f477d84e Mon Sep 17 00:00:00 2001 From: Decrypt <90518536+Decryptu@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:16:58 +0200 Subject: [PATCH 3/8] Walk Red, Blue and Yellow through the Rocket Hideout and Pokemon Tower MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The story walk runs on from Erika to the poster's guard, four hideout floors down to the LIFT KEY, the car to Giovanni and the SILPH SCOPE, the car back up, Lavender's tower floor by floor past the rival, the channelers and the ghost MAROWAK, and Mr. Fuji's POKé FLUTE, on all three cartridges. Fixed at their seams and pinned in the checks: - A trainer header's text can be machine code. Red's `RocketHideoutB4FRocket3AfterBattleText` is `text_asm` whole, Lance's after-battle text sets EVENT_BEAT_LANCE behind its line, and Yellow's end-battle text reaches the LIFT KEY's `ShowObject` behind a `text_promptbutton`. All three header texts decode as scripts now; the after one is resolved in place of the line and the end one runs behind the won fight. The LIFT KEY had never appeared and EVENT_BEAT_LANCE had never been set. - `wBattleResult` at zero had no row in `GEN1_BATTLE_OUTCOMES`, so the tower's MAROWAK stood again after every won fight. - A header's plain lines are filled the way a script's box is. - The story tool plans through spin tiles, one edge per ride, and answers `DisplayElevatorFloorMenu` with a standing floor. --- README.md | 2 +- game/battle/ai_switch.gd | 7 - game/battle/move_effect.gd | 7 - game/gen1/gen1_world_importer.gd | 20 +- game/render/gen2_screen.gd | 9 - game/render/pic_image.gd | 7 - game/render/second_screen.gd | 7 - game/world/world_api.gd | 32 +++- game/world/world_script_runner.gd | 8 - tests/unit/test_source_budget.gd | 2 +- tools/checks/gen1_trainers.gd | 38 +++- tools/checks/gen1_walk.gd | 39 ++++ tools/preview_world_story.gd | 304 +++++++++++++++++++++++++++++- 13 files changed, 413 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 9d88126f..57512b44 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ The rest are previews and dumps, each driving a real screen or table: ```bash # the full walked route: Johto, the Hall of Fame, every Kanto gym, and Red godot --headless --path . -s res://tools/preview_world_story.gd -- crystal 24 7 2 2 1 none home story -# Red, Blue or Yellow from the bedroom to Erika +# Red, Blue or Yellow from the bedroom to the POKé FLUTE godot --headless --path . -s res://tools/preview_world_story.gd -- red 0 38 3 6 1 none home story ``` diff --git a/game/battle/ai_switch.gd b/game/battle/ai_switch.gd index 6100a2f6..1522dd32 100644 --- a/game/battle/ai_switch.gd +++ b/game/battle/ai_switch.gd @@ -4,7 +4,6 @@ extends RefCounted ## Whether a trainer pulls its Pokemon out, and which one it sends instead: ## `CheckAbleToSwitch` and its five party scans in `engine/battle/ai/switch.asm`, ## plus the three frequency gates in `items.asm`. -## ## The cartridge works in six-bit masks and reuses one variable as both a score ## and a party index; here the masks are arrays of indices and the two uses are ## separate return values. Nothing changes: every scan keeps party order, and @@ -51,7 +50,6 @@ const THREAT_LOW: int = 5 ## Whether this trainer switches, and to which party index. -## ## Answers [code]{"switch": bool, "index": int}[/code]. [param flags] is the ## class's own [constant Gen2Layout.ATTR_AI_ITEM_SWITCH] word; a class with none ## of the three switch bits never switches, which is `AI_SwitchOrTryItem`'s own @@ -75,7 +73,6 @@ static func decide(battle: Gen2Battle, flags: int, rng: RandomNumberGenerator) - ## `FindMonInOTPartyToSwitchIntoBattle`: who the AI would rather have in, with no ## opinion about whether it should switch at all. -## ## [method decide] is the ordinary route and answers both questions at once. ## Baton Pass is the one caller that has already settled the first, so it needs ## the pick on its own. Nobody standing answers -1; a shortlist that resists @@ -171,7 +168,6 @@ static func _counter_choice(battle: Gen2Battle, immune: Array) -> Dictionary: ## `CheckPlayerMoveTypeMatchups`: how well the Pokémon that is out is doing, ## starting at [constant BASE_SCORE]. Higher is better for staying in. -## ## The first half reads what the player has actually thrown so far, falling back ## to the player's own types when it has thrown nothing. The second half reads ## what the enemy's own moves would do back. @@ -296,7 +292,6 @@ static func _alive_enough_to_switch(battle: Gen2Battle) -> bool: ## `FindEnemyMonsWithAtLeastQuarterMaxHP`, narrowing whoever was passed in. -## ## The name is the only quarter in it. `AICheckEnemyQuarterHP` doubles the HP ## word twice with `sla c / rl b`; this routine writes `srl c / rl b`, so the low ## byte is halved twice while the high byte is doubled twice and picks up the two @@ -313,7 +308,6 @@ static func _at_least_quarter_hp(battle: Gen2Battle, candidates: Array) -> Array ## The `srl c / rl b` pair run twice over the HP word, as a number. -## ## For a maximum HP under 256 the whole thing comes to "current HP is not a ## multiple of four": the high byte ends up holding the two bits the low byte ## dropped, so anything but a multiple of four lands over 255 and clears any @@ -364,7 +358,6 @@ static func _resisting(battle: Gen2Battle, candidates: Array) -> Array: ## `FindEnemyMonsWithASuperEffectiveMove`, with `FindAliveEnemyMons` folded in ## the way `FindAliveEnemyMonsWithASuperEffectiveMove` folds it. -## ## Answers [code]{"index": int, "quality": int}[/code]: quality 2 for the first ## candidate holding a move that is super effective against whoever the player ## has out, 1 for the first that at least has a neutral one, and 0 for nobody. diff --git a/game/battle/move_effect.gd b/game/battle/move_effect.gd index 3b19c4ed..4926d76a 100644 --- a/game/battle/move_effect.gd +++ b/game/battle/move_effect.gd @@ -9,7 +9,6 @@ extends RefCounted ## The effect bytes with a list of their own, numbered as the cartridge's move ## table numbers them. -## ## Recoil is here because Struggle needs it: without it two empty Pokémon never ## finish their battle. The rest are the status conditions in their two shapes, a ## move whose whole purpose is the status and a move that damages and leaves @@ -1030,7 +1029,6 @@ const BATON_PASS_SEQUENCE: Array = [ ] ## Whirlwind and Roar. `checkhit` is the one thing in front of the command. -## ## The list carries no `failuretext`, so on the cartridge a missed Whirlwind ## reaches `forceswitch`, takes `.missed` and says "But it failed!" with no miss ## line at all. Here `checkhit` ends the move and announces the miss, which is @@ -1113,7 +1111,6 @@ const PURSUIT_SEQUENCE: Array = [ ## Beat Up: one pass of the loop per party member, `endloop` jumping back to ## `critical` rather than to the top, so `checkhit` is outside the loop and rolls ## once for the whole move. -## ## No `damagestats` and no `stab`: the command loads the formula's two stats ## itself, from base stats rather than from either Pokémon's real ones, and ## nothing multiplies the result by a matchup. `CheckTurn` leaves `wTypeModifier` @@ -1198,7 +1195,6 @@ const MEAN_LOOK_SEQUENCE: Array = [ ## [constant MULTI_HIT] and [constant DOUBLE_HIT]. `startloop` and `endloop` ## bracket the hit and `endloop` rewinds to `critical`, so the accuracy roll and ## the doll are outside the loop and the damage is worked out again per hit. -## ## The count is rolled by `endloop` on its first pass, which is *behind* the ## first hit's own spread: rolling it in front would take the same seed to a ## different battle. @@ -2116,7 +2112,6 @@ static func _table() -> Dictionary: ## The commands a move with this effect byte is made of. -## ## A registered effect wins over the cartridge's, which is what lets a mod ## rewrite one as well as add one. [method register_effect] is where that is ## refused for the effects the engine relies on reading back off a turn. @@ -2149,7 +2144,6 @@ const RESERVED_EFFECTS: Array[int] = [ ## Registers the command list a move carrying [param effect] runs. -## ## Every step named has to be one the engine knows or one already registered ## through [method register_command], so a list that would push an error mid-turn ## is refused here, where the mod's id is still in hand. @@ -2181,7 +2175,6 @@ static func register_effect(id: StringName, effect: int, commands: Array) -> Dic ## Registers a step a command list may name, run with the [Gen2Turn] the way ## every built-in step is. -## ## The engine's own commands are tried first, so a registration cannot shadow ## [constant Gen2EffectCommands.APPLY_DAMAGE] and quietly change what every move ## in the game does. diff --git a/game/gen1/gen1_world_importer.gd b/game/gen1/gen1_world_importer.gd index 9d1fdc43..39400f71 100644 --- a/game/gen1/gen1_world_importer.gd +++ b/game/gen1/gen1_world_importer.gd @@ -5581,15 +5581,25 @@ static func _read_trainer_header( + rom.u8(at + int(offsets["flag_bit"])), "sight_range": rom.u8(at + int(offsets["range"])) >> Gen1Layout.TRAINER_RANGE_SHIFT, } + ## The LIFT KEY's `ShowObject` is machine code in a header text, Red's + ## after-battle one and Yellow's end-battle one. for name: String in ["before", "after", "end"]: - out[name] = _read_trainer_text( - rom, layout, bank, rom.u16le(at + int(offsets[name])) - ) + var pointer: int = rom.u16le(at + int(offsets[name])) + out[name] = _read_trainer_text(rom, layout, bank, pointer) + var decoded: Dictionary = Gen1Text.decode_stream(rom, Gen1Layout.banked(bank, pointer)) + var code: int = _text_code_at(decoded) + if code < 0: + continue + var script: Array = decode_script(rom, layout, bank, code) + if script.is_empty(): + continue + ## Lance's `SetEvent EVENT_BEAT_LANCE` stands behind the streamed line. + if not String(decoded.get("text", "")).is_empty(): + script.push_front({"op": "text", "text": String(decoded["text"])}) + out["%s_script" % name] = script return out -## One of a header's three texts. `RocketHideoutB4FRocket3AfterBattleText` is -## the corpus's one that is machine code, and says its `PrintText` operand. static func _read_trainer_text( rom: RomFile, layout: Dictionary, bank: int, pointer: int ) -> String: diff --git a/game/render/gen2_screen.gd b/game/render/gen2_screen.gd index 4ad186f4..785ffdb7 100644 --- a/game/render/gen2_screen.gd +++ b/game/render/gen2_screen.gd @@ -78,7 +78,6 @@ var subpixel: bool = false: var _subpixel_steps: int = 1 ## Whether the buffer outside the 160x144 rectangle is filled by this screen. -## ## On by default, and the reason a screen written without a thought for the ## window is still not letterboxed: a screen laid out in 160x144 has nothing of ## its own to put in a wider buffer, so the surround is its own field and the @@ -109,7 +108,6 @@ var surround_color: Color = Color.BLACK: _mask.queue_redraw() ## Whether anything has said what [member surround_color] is. -## ## "Not told yet" is not "told it is black", and the difference is a whole class ## of bug: a screen laid out over another -- a menu box over the map, a question ## over a picture -- has no field of its own, and painting the default over the @@ -204,7 +202,6 @@ func apply_screen_fill() -> void: ## Inside the screen, in hardware pixels: position it in the 160x144 space. -## ## Everything placed this way is interface, and sits above whatever ## [method display_content] put there, in the order it was placed. func display(node: Node) -> void: @@ -243,7 +240,6 @@ func view_size() -> Vector2i: ## The cartridge's own 160x144 screen, in the native layer's own pixels. -## ## A view on that layer is handed a rectangle and told nothing else, and every ## hardware-pixel number it is given -- the text box's, first -- has to land ## somewhere inside it. Framed, that mapping was the layer itself, since the @@ -350,7 +346,6 @@ func interface_layer() -> Control: ## Where the hardware's own 160x144 sits inside the buffer, in buffer pixels. -## ## What a screen drawing a backdrop has to leave alone: the picture it hands ## [method set_backdrop] is the buffer's size, and this is the hole its own ## frame is already filling. @@ -359,10 +354,8 @@ func interface_origin() -> Vector2i: ## The screen [param node] is drawn on, or null outside one. -## ## The walk rather than a stored reference: a screen is added to whichever host ## has one and freed by it, and neither end should have to hold the other. -## ## The outermost one, not the nearest, for a node that is somehow inside two. static func owner_of(node: Node) -> Gen2Screen: var at: Node = node @@ -431,7 +424,6 @@ func _on_backdrop_source_gone() -> void: ## The 160x144 field a screen stands on when it is a colour rather than a ## picture: a [ColorRect] that says what colour it is, so the screen around it ## carries the same one out to the window. -## ## [method Gen2PicImage.show] is that seam for a screen drawn as a picture and ## this is the other half of it. It reports on every draw rather than on a ## setter, so a fade that recolours the field takes the surround with it without @@ -658,7 +650,6 @@ func _draw_cover() -> void: ## the interface is laid out in. Not a filled surface, because the middle is not ## always the interface's -- a battle transition is the renderer's own screen and ## has to stay visible inside it. -## ## A backdrop the size of the buffer is drawn through the same four bands, so a ## screen with real art out there paints it and a screen without paints its own ## field colour. diff --git a/game/render/pic_image.gd b/game/render/pic_image.gd index 1d3c6b1d..0b3a0dc7 100644 --- a/game/render/pic_image.gd +++ b/game/render/pic_image.gd @@ -5,7 +5,6 @@ extends RefCounted ## cartridge stores, two bits per pixel and no colour, so the palette is chosen ## here at draw time and a shiny sprite costs one [PackedColorArray] rather than ## a second copy of the pixels. -## ## Built as one buffer for [method Image.create_from_data]: per-pixel ## [method Image.set_pixel] on a 56x56 sprite is 3136 binding calls for a table ## lookup, and a battle can want several sprites a frame. Node-free, so headless. @@ -68,7 +67,6 @@ static func canvas_from_indices( ## tile, so a screen the hardware draws in several palettes is one buffer here ## too rather than a layer per colour. [param slots] is [param columns] wide and ## row-major, and a slot naming no palette falls back on the first. -## ## `FillBoxCGB` and `ByteFill` over an attrmap `WipeAttrmap` cleared are what ## every `_CGB_*` layout writes, so a caller's own `attributes()` is that list of ## boxes flattened once. @@ -167,7 +165,6 @@ static func from_atlas( ## The same cell before a palette is chosen: { indices, width, height }, or empty ## for a slot the atlas does not hold. Split out so a palette fade over a ## frontpic swaps colours rather than cropping the atlas again. -## ## A [param pic] carrying its own [code]indices[/code] is a mod's picture and has ## no cell to crop: the atlases hold exactly the cartridge's slots. Answering it ## here is what lets every screen draw one without knowing which it has. @@ -213,7 +210,6 @@ static func atlas_cell( ## One packed RGBA8 word per colour index, so a blit is an array copy rather ## than a [Color] conversion. Little-endian, which is the order ## [method PackedInt32Array.to_byte_array] writes and `FORMAT_RGBA8` reads. -## ## Public because every per-tile blit in the project shares it: a page that ## builds a [Color] per pixel pays a binding call and an allocation for a table ## lookup, which is what [method blit_tile] exists to stop. @@ -370,7 +366,6 @@ static func x_flipped_indices(indices: PackedByteArray, width: int) -> PackedByt ## The palette as the eight-bit colours a blit actually writes. -## ## For a caller comparing a rendered pixel against a palette: a 15-bit colour is ## kept as a float and read back through an eight-bit image, so the comparison ## belongs where the picture is and the conversion has to be the drawing's own. @@ -389,7 +384,6 @@ static func quantized( ## A blank canvas [param width] x [param height], one packed RGBA8 word per ## pixel. Every word is zero, which is transparent black. -## ## A word rather than four bytes because the inner loop of every page in the ## project is one store per pixel either way, and four of them cost four bounds ## checks; the conversion to bytes at the end is one memcpy. @@ -458,7 +452,6 @@ static func blit_tile( ## Puts [param image] on [param target]. -## ## The one way a screen in this project hands a redrawn picture to the node that ## shows it, so the reuse below happens everywhere rather than at whichever ## screen was measured. diff --git a/game/render/second_screen.gd b/game/render/second_screen.gd index 230a2613..4cabea3e 100644 --- a/game/render/second_screen.gd +++ b/game/render/second_screen.gd @@ -33,7 +33,6 @@ const FIELD_COLOR := Color(0.0, 0.0, 0.0, 1.0) ## What the panel shows with no world on it, drawn in the launcher's own ## language rather than the cartridge's: an empty cartridge silhouette, the ## project's name and a line saying nothing is running. -## ## The launcher measures in points, so the design is written in these units and ## drawn at whatever whole multiple of them the panel is; a launcher unit is ## about a point at [constant IDLE_UNITS] on a handheld's lower display. @@ -129,7 +128,6 @@ func set_world(data: GameData, world: Gen2WorldAPI, save: Gen2SaveData) -> void: ## Re-reads the world: which tabs are open, and whether the page on screen is ## still the one the cursor names. -## ## Called every hardware frame and does nothing on almost all of them: the gates ## are three numbers and the row's one live picture is the party's lead, so the ## whole question is a short string. Rebuilding the START menu here instead would @@ -168,7 +166,6 @@ func _gate() -> String: ## Which tab a tap at [param at] landed on, counting from zero, or -1 for a tap ## that missed the row. -## ## Static and pure, so where a touch lands is asserted without a display: the row ## is the only part of this screen that takes one. static func tab_index_at(at: Vector2, canvas: Vector2i, count: int) -> int: @@ -197,7 +194,6 @@ static func tab_cell(index: int, canvas: Vector2i, count: int) -> Rect2i: ## The tab the tap at [param at] landed on, in canvas pixels, or the empty name ## for a tap that missed the row. -## ## Public because a host on a real panel converts its own touch to these pixels ## and a test drives it without one. func tab_at(at: Vector2) -> StringName: @@ -208,7 +204,6 @@ func tab_at(at: Vector2) -> StringName: ## A touch on the tab row, in canvas pixels. Answers whether it opened a page. -## ## The only input this screen accepts, and the reason it stays read only: there ## is no path from here into a page. func touch(at: Vector2) -> bool: @@ -439,7 +434,6 @@ func _cell(index: int) -> Rect2: ## The row as the cartridge would have drawn it: the player's own text-box frame ## around white paper, with the open tab underlined in the frame's ink. -## ## Redrawn when the tab set or the chosen tab changes, not per frame. Nothing ## here is invented: the six frame tiles are `LoadFrame`'s own, chosen by the ## same FRAME option the boxes on the top screen wear. @@ -468,7 +462,6 @@ func _redraw_strip() -> void: ## The six frame tiles around the row. -## ## Placed by hand rather than through [method Gen2Font.draw_box], because that ## one takes whole tiles in both directions and this row is a whole number of ## tiles in neither: the border tiles are laid at the four edges and the runs diff --git a/game/world/world_api.gd b/game/world/world_api.gd index f8dcca6d..76556362 100644 --- a/game/world/world_api.gd +++ b/game/world/world_api.gd @@ -3837,6 +3837,8 @@ const GEN1_BATTLE_OUTCOMES: Dictionary = { Gen1Layout.BATTLE_OUTCOME_ESCAPED: [ Gen2WorldBattleAdapter.OUTCOME_CAUGHT, Gen2WorldBattleAdapter.OUTCOME_RAN, ], + ## `wBattleResult` at zero, which the tower's MAROWAK reads with `and a`. + Gen1Layout.BATTLE_OUTCOME_WON: [Gen2WorldBattleAdapter.OUTCOME_WON], } ## What the last battle a script asked for answered. @@ -5903,9 +5905,12 @@ func _gen1_trainer_steps(row: Dictionary, event: Dictionary) -> Array: var flag: int = int(header["event_flag"]) _gen1_scratch[int(Gen1Layout.for_id(data.id)["trainer_header_flag_bit"])] = flag % 8 if event_flag_active(flag): - return [{"type": &"text", "text": String(header["after"])}] + if header.has("after_script"): + var steps: Array = [] + return steps if _gen1_resolve_script(header["after_script"], steps, _gen1_run(event)) else [] + return [{"type": &"text", "text": gen1_filled_text(String(header["after"]))}] return [ - {"type": &"text", "text": String(header["before"])}, + {"type": &"text", "text": gen1_filled_text(String(header["before"]))}, { "type": &"request", "values": { @@ -5915,6 +5920,7 @@ func _gen1_trainer_steps(row: Dictionary, event: Dictionary) -> Array: "trainer_flag": flag, "object_index": int(event.get("object_index", -1)), "standing_wild": not event.has("trainer_class"), + "end_script": header.get("end_script", []), }, ] @@ -6759,6 +6765,14 @@ func _gen1_battle_won(step: Dictionary, result: Dictionary) -> void: if bool(step.get("standing_wild", false)): gen1_toggle_object(_gen1_toggle_index(int(step.get("object_index", -1))), true) load_object_masks() + ## `TextCommand_ASM` behind the end-battle line the fight printed. + var ending: Array = [] + for node: Dictionary in step.get("end_script", []) as Array: + if String(node.get("op", "")) != "text": + ending.append(node) + var steps: Array = [] + if not ending.is_empty() and _gen1_resolve_script(ending, steps, _gen1_run({})): + _gen1_steps = steps + _gen1_steps ## `CollisionCheckOnLand` skips every test while `wSimulatedJoypadStatesIndex` @@ -6766,7 +6780,7 @@ func _gen1_battle_won(step: Dictionary, result: Dictionary) -> void: func _gen1_walk_player(moves: Array) -> Array: var generated: Array = [] for leg: Dictionary in moves: - var direction: Vector2i = _movement_direction(int(leg["direction"])) + var direction: Vector2i = movement_direction(int(leg["direction"])) for _step: int in int(leg["steps"]): var destination: Vector2i = player_cell + direction if not _cell_in_bounds(destination): @@ -6794,7 +6808,7 @@ func _gen1_walk_object(index: int, moves: Array) -> Array: _gen1_stand_object(index) var facing: int = object.facing for row: int in moves: - var direction: Vector2i = _movement_direction(row) + var direction: Vector2i = movement_direction(row) facing = facing_for_direction(direction) var destination: Vector2i = object.cell + direction if not _cell_in_bounds(destination): @@ -8018,12 +8032,12 @@ func _apply_object_movement(event: Dictionary) -> Array: if kind in SCRIPTED_TURN_KINDS: ## Queued rather than applied, so a turn behind a walk turns where ## the walk ends. `queue_step` drains an entry of no frames itself. - var turn: Vector2i = _movement_direction(int(command.get("direction", 0))) + var turn: Vector2i = movement_direction(int(command.get("direction", 0))) object.queue_step(Vector2i.ZERO, 0, false, turn) final_facing = facing_for_direction(turn) continue if SCRIPTED_STEP_PASSES.has(kind): - var direction: Vector2i = _movement_direction(int(command.get("direction", 0))) + var direction: Vector2i = movement_direction(int(command.get("direction", 0))) var jumping: bool = kind in JUMP_STEP_KINDS var cells: int = 2 if jumping else 1 var destination: Vector2i = object.cell + direction * cells @@ -8152,11 +8166,11 @@ func _apply_player_movement(event: Dictionary) -> Array: ## the walk ends rather than on the frame the stream was applied. _queue_player_step( Vector2i.ZERO, 0, false, - _movement_direction(int(command.get("direction", 0))), kind, + movement_direction(int(command.get("direction", 0))), kind, ) continue if SCRIPTED_STEP_PASSES.has(kind): - var direction: Vector2i = _movement_direction(int(command.get("direction", 0))) + var direction: Vector2i = movement_direction(int(command.get("direction", 0))) var jumping: bool = kind in JUMP_STEP_KINDS var cells: int = 2 if jumping else 1 var destination: Vector2i = player_cell + direction * cells @@ -8205,7 +8219,7 @@ func _apply_player_movement(event: Dictionary) -> Array: return generated -func _movement_direction(direction: int) -> Vector2i: +func movement_direction(direction: int) -> Vector2i: match direction & 3: 0: return Vector2i.DOWN diff --git a/game/world/world_script_runner.gd b/game/world/world_script_runner.gd index 3a892a66..dcd17bd4 100644 --- a/game/world/world_script_runner.gd +++ b/game/world/world_script_runner.gd @@ -2,7 +2,6 @@ class_name Gen2WorldScriptRunner extends RefCounted ## Bounded, scene-free execution of the supported overworld script commands. -## ## A runner owns one event invocation. It never opens a ROM and it never ## changes the world state until the invocation reaches END or ENDCALLBACK. ## Text and explicit warps are returned as structured pauses for the screen or @@ -2710,7 +2709,6 @@ func _command_checkpokemail(_opcode: int, command: Dictionary, _bank: int) -> Di ## A command whose numbers a mod may have moved, substituted before it runs. -## ## The site is addressed by the byte it sits at, `frame.address + offset`, which ## is exactly the id [Gen2WorldCatalog] gave it. Only the OPERANDS change: the ## command still runs, its script still sets its own completion flag, prints its @@ -4121,7 +4119,6 @@ func _var_kenji_break_timer() -> Variant: ## store of wScriptVar into the address it answered. Only those rows can be ## written: every other entry hands back a copy in wStringBuffer2 or runs a ## routine, so a `writevar` naming one writes nothing the script can read back. -## ## Every `writevar` in either game is RadioTower2F's own award of a Blue Card ## point, which stopped the script here until this existed. func _write_runtime_variable(variable: int) -> Dictionary: @@ -5864,7 +5861,6 @@ func _battle_tower_party() -> Dictionary: ## `_CheckForBattleTowerRules`: every rule is run rather than stopping at the ## first failure, so a party can fail more than one and the receptionist says so ## once per failure. `BattleTower_PleaseReturnWhenReady` closes the run. -## ## wScriptVar is TRUE when something failed, which is what ## `ifnotequal FALSE, Script_WaitButton` refuses the challenge on. func _check_battle_tower_rules() -> Dictionary: @@ -6632,7 +6628,6 @@ func _byte_array(raw: Variant) -> PackedByteArray: ## `ReadCaughtData` and `SeerAction`, which are one reading of the row and then ## the boxes that reading picked. -## ## Nothing here writes anything: every branch is a run of `PrintText`s and the ## five buffers they read, so the whole routine is text. func _finish_poke_seer(special: int, result: Dictionary) -> Dictionary: @@ -6822,7 +6817,6 @@ func _stage_item_ball() -> Dictionary: ## `FruitTreeScript` (`engine/events/fruit_trees.asm`). Like an item ball it is ## a script rather than a host request: `fruittree` is the whole of the object's ## own script, and the routine below it is text, a flag and a `giveitem`. -## ## The first pause is `FruitBearingTreeText`; `TryResetFruitTrees` and ## `CheckFruitTree` run on its acknowledge, since the source's own `callasm`s sit ## behind the `promptbutton`. @@ -7312,7 +7306,6 @@ func text_context() -> Dictionary: ## The buffers this runner filled, keyed the way `TextCommand_RAM` asks for them. -## ## `getstring` and `verbosegiveitem` fill buffers by `text_buffer` number, but a ## `text_ram` names the same storage by its WRAM address, so the item texts read ## through StringBufferPointers rather than the number. Addresses come from the @@ -7435,7 +7428,6 @@ func _engine_flag_active(flag: int) -> bool: ## _GetVarAction's .CountBadges, over staged flags rather than committed ones. -## ## The cartridge has no staging: `setflag` writes wBadges and the `readvar` ## after it reads what was just written. Mahogany Gym is where that matters: ## PryceScript sets ENGINE_GLACIERBADGE, reads VAR_BADGES and branches on 7 to diff --git a/tests/unit/test_source_budget.gd b/tests/unit/test_source_budget.gd index ecdb1410..ad8855b3 100644 --- a/tests/unit/test_source_budget.gd +++ b/tests/unit/test_source_budget.gd @@ -21,7 +21,7 @@ const MAX_COMMENT_BLOCK: int = 8 ## the shared code, the largest of them 152 the region map, 150 the battle ## animation engine, 140 the transition, 131 the field moves, 130 the Pokedex, ## 108 the three PCs, 90 the Safari Zone, 52 the menus a row draws. -const MAX_COMMENT_LINES: int = 41981 +const MAX_COMMENT_LINES: int = 41967 ## What no comment block ever ends on. A pass that meets the ceiling above trims ## the second line of a two-line block and leaves the first mid-sentence, which diff --git a/tools/checks/gen1_trainers.gd b/tools/checks/gen1_trainers.gd index 485dd808..5b0178c7 100644 --- a/tools/checks/gen1_trainers.gd +++ b/tools/checks/gen1_trainers.gd @@ -15,9 +15,16 @@ const CLASS_COUNT: int = 47 ## `text_asm` rows, the ones reaching `TalkToTrainer`, and the objects naming ## one: a trainer class above `OPP_ID_OFFSET`, or a standing wild below it. const HEADER_CENSUS: Dictionary = { - &"red": {"text_asm": 638, "headers": 322, "trainers": 310, "wilds": 12}, - &"blue": {"text_asm": 638, "headers": 322, "trainers": 310, "wilds": 12}, - &"yellow": {"text_asm": 691, "headers": 317, "trainers": 305, "wilds": 12}, + &"red": {"text_asm": 638, "headers": 322, "trainers": 310, "wilds": 12, "coded": 3}, + &"blue": {"text_asm": 638, "headers": 322, "trainers": 310, "wilds": 12, "coded": 3}, + &"yellow": {"text_asm": 691, "headers": 317, "trainers": 305, "wilds": 12, "coded": 3}, +} +## The header texts whose machine code does more than print, by map and the +## flag each sets; Yellow moved the LIFT KEY's `ShowObject` to the end text. +const CODED_HEADER_FLAGS: Dictionary = { + &"red": {"113:after": 2302, "199:end": 1653, "202:after": 1702}, + &"blue": {"113:after": 2302, "199:end": 1653, "202:after": 1702}, + &"yellow": {"113:after": 2302, "199:end": 1653, "202:end": 1702}, } ## `view_range << 4` is a pixel distance, so the stored range is a nibble. @@ -175,7 +182,8 @@ func _the_party_table() -> void: ## Every header the corpus carries, and the object each belongs to: three texts, ## a range inside its nibble, a flag above zero, and a party that is stored. func _the_headers() -> void: - var census: Dictionary = {"text_asm": 0, "headers": 0, "trainers": 0, "wilds": 0} + var census: Dictionary = {"text_asm": 0, "headers": 0, "trainers": 0, "wilds": 0, "coded": 0} + var coded: Dictionary = {} for map: Gen2WorldMap in _r.data.world_maps(): for row: Dictionary in map.texts: if int(row.get("command", 0)) == Gen1Layout.TEXT_ASM: @@ -183,6 +191,12 @@ func _the_headers() -> void: if row.has("trainer"): census["headers"] += 1 _one_header(map, row["trainer"]) + for name: String in ["before", "after", "end"]: + var flag: int = _first_flag((row["trainer"] as Dictionary).get("%s_script" % name, [])) + if flag < 0: + continue + census["coded"] += 1 + coded["%d:%s" % [map.number, name]] = flag for object: Dictionary in map.events["objects"] as Array: var header: Dictionary = _header_for(map, object) if header.is_empty(): @@ -206,11 +220,19 @@ func _the_headers() -> void: "map %d's standing wild is outside ToggleableObjectStates." % map.number) _r.check(census == HEADER_CENSUS[_r.game_id], "the header census reads %s." % str(census)) + _r.check(coded == CODED_HEADER_FLAGS[_r.game_id], "the coded header texts set %s." % [coded]) _r.note("gen1 trainers %d headers on %d text_asm rows" % [ int(census["headers"]), int(census["text_asm"]), ]) +func _first_flag(script: Array) -> int: + for node: Dictionary in script: + if String(node.get("op", "")) == "flag": + return int(node["flag"]) + return -1 + + func _one_header(map: Gen2WorldMap, header: Dictionary) -> void: _r.check(int(header["event_flag"]) > 0, "map %d has a header on flag %d." % [map.number, int(header["event_flag"])]) @@ -275,7 +297,7 @@ func _talk_to( var where: String = "map %d text %d" % [map.number, int(object.get("text", 0))] if not _r.check(not opened.is_empty(), "%s said nothing." % where): return false - if not _r.check(_event_text(opened) == String(header["before"]), + if not _r.check(_event_text(opened) == world.gen1_filled_text(String(header["before"])), "%s opened with %s." % [where, _event_text(opened)]): return false var request: Dictionary = _request_after(world) @@ -290,8 +312,10 @@ func _talk_to( if not object.has("trainer_class"): return _r.check(_face(world, object).is_empty(), "%s is still on the map." % where) var again: Array = _face(world, object) - var answered: bool = _r.check(_event_text(again) == String(header["after"]), - "%s finished with %s." % [where, _event_text(again)]) + var answered: bool = _r.check( + _event_text(again) == world.gen1_filled_text(String(header["after"])), + "%s finished with %s." % [where, _event_text(again)] + ) world.run_event_queue(true) return answered diff --git a/tools/checks/gen1_walk.gd b/tools/checks/gen1_walk.gd index 0756eb52..cfe73466 100644 --- a/tools/checks/gen1_walk.gd +++ b/tools/checks/gen1_walk.gd @@ -495,6 +495,7 @@ func _one_game() -> void: _check_the_saffron_guard() _check_an_arrow_tile() _check_a_scripted_wild_battle() + _check_the_ghost_marowak() _check_the_catch_training() if _r.game_id != RomRegistry.YELLOW: _check_the_opening_walk() @@ -2709,6 +2710,44 @@ func _check_a_scripted_wild_battle() -> void: _r.note("gen1 walk ROUTE_12's Snorlax fought at level %d" % SNORLAX_LEVEL) +## `PokemonTower6FMarowakBattleScript` reads `wBattleResult` with `and a`: only +## a won fight sets EVENT_BEAT_GHOST_MAROWAK and prints the departure. +const POKEMON_TOWER_6F: int = 147 +const POKEMON_TOWER_6F_BYTE: int = 63 +const MAROWAK_CELL := Vector2i(10, 16) +const MAROWAK_SPECIES: int = 105 +const MAROWAK_LEVEL: int = 30 +const MAROWAK_BEAT_FLAG: int = 271 +const MAROWAK_DEPARTED: String = "The GHOST was" + + +func _check_the_ghost_marowak() -> void: + for outcome: StringName in [Gen2WorldBattleAdapter.OUTCOME_RAN, Gen2WorldBattleAdapter.OUTCOME_WON]: + var world: Gen2WorldAPI = _r.open_world(0, POKEMON_TOWER_6F, MAROWAK_CELL + Vector2i.RIGHT) + if world == null: + return + world.player_cell = MAROWAK_CELL + var opened: Array = world.dispatch_sight_events() + var passes: int = 0 + while world.pending_runtime_request().is_empty() and passes < SCRIPTED_WALK_PASSES: + world.run_event_queue(true) + passes += 1 + var values: Dictionary = world.pending_runtime_request().get("values", {}) as Dictionary + if not _r.check( + int(values.get("pokemon", 0)) == MAROWAK_SPECIES and int(values.get("level", 0)) == MAROWAK_LEVEL, + "the ghost asked for %s / %s." % [world.pending_runtime_request(), opened] + ): + return + world.complete_runtime_request({"ok": true, "outcome": outcome}) + var after: Array = world.dispatch_sight_events() + var won: bool = outcome == Gen2WorldBattleAdapter.OUTCOME_WON + _r.check(world.event_flag_active(MAROWAK_BEAT_FLAG) == won, + "a fight %s left EVENT_BEAT_GHOST_MAROWAK %s." % [outcome, world.event_flag_active(MAROWAK_BEAT_FLAG)]) + _r.check((not after.is_empty() and _event_text(after).begins_with(MAROWAK_DEPARTED)) == won, + "a fight %s was answered with %s." % [outcome, after]) + _r.note("gen1 walk POKEMON_TOWER_6F's MAROWAK fought at level %d" % MAROWAK_LEVEL) + + ## `ViridianCityOldManStartCatchTrainingScript`'s `wBattleType` as the Dude's ## tutorial, and `wCurOpponent` read behind the whole state: Yellow's initial ## training sets EVENT_INITIAL_CATCH_TRAINING after the store and breaks out on it. diff --git a/tools/preview_world_story.gd b/tools/preview_world_story.gd index 6aa9e326..542919ad 100644 --- a/tools/preview_world_story.gd +++ b/tools/preview_world_story.gd @@ -28,8 +28,12 @@ const REQUEST_HANDLERS: Dictionary = { &"audio_requested": &"_request_audio", &"magnet_train_requested": &"_request_magnet_train", &"pokedex_entry_requested": &"_request_audio", + &"elevator_requested": &"_request_elevator", } +## The map number the next `DisplayElevatorFloorMenu` is answered with; -1 is B. +var _gen1_elevator_floor: int = -1 + ## SPECIALCALL_ASSISTANT (constants/phone_constants.asm), armed by beating ## Falkner and answered by ElmPhoneCallerScript's .assistant branch. const SPECIALCALL_ASSISTANT: int = 3 @@ -8787,6 +8791,17 @@ func _request_apricorns(world: Gen2WorldAPI, _request: Dictionary, state: Dictio return given.get("results", []) +func _request_elevator(world: Gen2WorldAPI, request: Dictionary, state: Dictionary) -> Array: + var floors: Array = (request.get("values", {}) as Dictionary).get("floors", []) + for row: Dictionary in floors: + if int(row.get("map", -1)) == _gen1_elevator_floor: + _gen1_elevator_floor = -1 + return world.complete_runtime_request({"ok": true, "floor": row}) + if _gen1_elevator_floor >= 0: + return _request_failed(state, "the car lists no floor on map %d" % _gen1_elevator_floor, {"floors": floors}) + return world.complete_runtime_request({"ok": true}) + + ## `DisplayPokedex` behind a Generation 1 ball is a page with nothing to answer, ## the way `waitsfx` is. func _request_audio(world: Gen2WorldAPI, _request: Dictionary, _state: Dictionary) -> Array: @@ -9006,7 +9021,9 @@ func _walk_to_story_cell( } var steps: Array[Vector2i] = plan["steps"] var events: Array = [] + var arrows: Dictionary = _gen1_arrow_landings(world) for direction: Vector2i in steps: + var expected: Vector2i = arrows.get(world.player_cell + direction, world.player_cell + direction) var moved: Dictionary = world.move_result(direction) if not bool(moved.get("ok", false)): return { @@ -9016,7 +9033,8 @@ func _walk_to_story_cell( ], } events = _dispatch_after_step(world) - if not events.is_empty(): + ## A spin tile has already moved the player off the plan. + if not events.is_empty() or world.player_cell != expected: break return {"ok": true, "steps": steps.size(), "events": events} @@ -9025,6 +9043,7 @@ func _plan_walk(world: Gen2WorldAPI, target: Vector2i, water_only: bool = false) var frontier: Array[Vector2i] = [world.player_cell] var previous: Dictionary = {world.player_cell: {"cell": Vector2i(-1, -1), "direction": Vector2i.ZERO}} var directions: Array[Vector2i] = [Vector2i.UP, Vector2i.DOWN, Vector2i.LEFT, Vector2i.RIGHT] + var arrows: Dictionary = _gen1_arrow_landings(world) var found: bool = false while not frontier.is_empty(): var cell: Vector2i = frontier.pop_front() @@ -9035,6 +9054,8 @@ func _plan_walk(world: Gen2WorldAPI, target: Vector2i, water_only: bool = false) var next: Vector2i = _reachable_step( world, cell, direction, target, water_only and cell + direction != target ) + ## A spin tile is one edge to wherever its ride stops. + next = arrows.get(next, next) if next.x < 0 or previous.has(next): continue previous[next] = {"cell": cell, "direction": direction} @@ -9048,6 +9069,34 @@ func _plan_walk(world: Gen2WorldAPI, target: Vector2i, water_only: bool = false) return {"found": found, "steps": steps, "previous": previous} +## Each spin tile and the cell `DecodeArrowMovementRLE`'s whole ride stops on. +func _gen1_arrow_landings(world: Gen2WorldAPI) -> Dictionary: + var out: Dictionary = {} + if world == null or not world.is_gen1() or world.current_map == null: + return out + var pending: Array = [world.current_map.scripts.get("states", [])] + while not pending.is_empty(): + var nodes: Variant = pending.pop_back() + if not nodes is Array: + continue + for node: Variant in nodes as Array: + if not node is Dictionary: + continue + if node.has("nodes"): + pending.append(node["nodes"]) + for key: String in Gen1Layout.SCRIPT_BRANCH_KEYS: + if node.has(key): + pending.append(node[key]) + if String(node.get("op", "")) != "arrow_movement": + continue + for cell: Dictionary in node["cells"] as Array: + var landing := Vector2i(int(cell["x"]), int(cell["y"])) + for leg: Dictionary in cell["moves"] as Array: + landing += world.movement_direction(int(leg["direction"])) * int(leg["steps"]) + out[Vector2i(int(cell["x"]), int(cell["y"]))] = landing + return out + + ## What a failed walk hit, when what it hit was somebody standing there. ## `Gen2WorldAPI.can_walk_to()` refuses a cell an object holds exactly as it refuses ## a wall, so without this a route blocked by an NPC or an item ball reads the same @@ -9379,6 +9428,7 @@ const GEN1_VERMILION_TREE_INSIDE := Vector2i(15, 19) const GEN1_CELADON_GYM_TREE_APPROACH := Vector2i(5, 8) ## The tree at (35, 32), which seals the gym's own corner of Celadon City. const GEN1_CELADON_TREE_APPROACH := Vector2i(35, 31) +const GEN1_CELADON_TREE_INSIDE := Vector2i(35, 33) ## Rock Tunnel's four ladders in walking order, each a floor and its cell, and ## the south exit: `TilePairCollisionsLand`'s CAVERN rows are what make the ## floors a maze rather than open rock. @@ -9393,6 +9443,62 @@ const GEN1_ERIKA := Vector2i(4, 3) const GEN1_EVENT_GOT_TM21: int = 424 const GEN1_EVENT_BEAT_ERIKA: int = 425 const GEN1_BIT_RAINBOWBADGE: int = 3 +const GEN1_GAME_CORNER: int = 135 +const GEN1_ROCKET_HIDEOUT_B1F: int = 199 +const GEN1_ROCKET_HIDEOUT_B2F: int = 200 +const GEN1_ROCKET_HIDEOUT_B3F: int = 201 +const GEN1_ROCKET_HIDEOUT_B4F: int = 202 +const GEN1_ROCKET_HIDEOUT_ELEVATOR: int = 203 +const GEN1_GAME_CORNER_DOOR := Vector2i(28, 19) +## The poster's guard, and the stairs `GameCornerPosterText` writes in. +const GEN1_GAME_CORNER_ROCKET := Vector2i(9, 5) +const GEN1_GAME_CORNER_STAIRS := Vector2i(17, 4) +const GEN1_HIDEOUT_B1F_STAIRS := Vector2i(23, 2) +const GEN1_HIDEOUT_B2F_STAIRS := Vector2i(21, 8) +const GEN1_HIDEOUT_B3F_STAIRS := Vector2i(19, 18) +const GEN1_HIDEOUT_B4F_STAIRS := Vector2i(19, 10) +const GEN1_HIDEOUT_B3F_UP := Vector2i(25, 6) +const GEN1_HIDEOUT_B2F_ELEVATOR := Vector2i(24, 19) +## `RocketHideoutB4FRocket3` drops the LIFT KEY's ball beside him. +const GEN1_HIDEOUT_ROCKET_3 := Vector2i(11, 2) +const GEN1_BELOW_LIFT_KEY := Vector2i(10, 3) +const GEN1_HIDEOUT_CAR_PANEL := Vector2i(1, 2) +const GEN1_HIDEOUT_CAR_DOOR := Vector2i(2, 1) +## The two Rockets `RocketHideoutB4F`'s load callback opens the door on. +const GEN1_HIDEOUT_DOOR_ROCKETS: Array = [Vector2i(23, 12), Vector2i(26, 12)] +const GEN1_HIDEOUT_GIOVANNI := Vector2i(25, 3) +const GEN1_BELOW_SILPH_SCOPE := Vector2i(25, 3) +const GEN1_LIFT_KEY: int = 0x4A +const GEN1_SILPH_SCOPE: int = 0x48 +const GEN1_EVENT_FOUND_ROCKET_HIDEOUT: int = 441 +const GEN1_EVENT_ROCKET_DROPPED_LIFT_KEY: int = 1702 +const GEN1_EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI: int = 1703 +const GEN1_POKEMON_TOWER_1F: int = 142 +const GEN1_POKEMON_TOWER_2F: int = 143 +const GEN1_POKEMON_TOWER_3F: int = 144 +const GEN1_POKEMON_TOWER_4F: int = 145 +const GEN1_POKEMON_TOWER_5F: int = 146 +const GEN1_POKEMON_TOWER_6F: int = 147 +const GEN1_POKEMON_TOWER_7F: int = 148 +const GEN1_MR_FUJIS_HOUSE: int = 149 +const GEN1_HIDEOUT_B4F_ELEVATOR := Vector2i(24, 15) +const GEN1_HIDEOUT_B1F_ELEVATOR_STAIRS := Vector2i(21, 2) +## `RocketHideoutB1FDoorCallbackScript` shuts the car's pocket on this Rocket. +const GEN1_HIDEOUT_B1F_DOOR_ROCKET := Vector2i(28, 18) +const GEN1_LAVENDER_TOWER_DOOR := Vector2i(14, 5) +## The tower's stairs alternate sides; 6F's RARE CANDY ball plugs the one-cell +## gap on the way to the 7F stairs, and the ghost MAROWAK stands on (10, 16). +const GEN1_TOWER_EAST_STAIRS := Vector2i(18, 9) +const GEN1_TOWER_WEST_STAIRS := Vector2i(3, 9) +const GEN1_ABOVE_RARE_CANDY := Vector2i(6, 7) +const GEN1_TOWER_7F_STAIRS := Vector2i(9, 16) +const GEN1_MR_FUJI := Vector2i(10, 3) +const GEN1_BELOW_MR_FUJI_HOME := Vector2i(3, 2) +const GEN1_POKE_FLUTE: int = 0x49 +const GEN1_EVENT_BEAT_POKEMON_TOWER_RIVAL: int = 239 +const GEN1_EVENT_BEAT_GHOST_MAROWAK: int = 271 +const GEN1_EVENT_GOT_POKE_FLUTE: int = 296 +const GEN1_EVENT_RESCUED_MR_FUJI: int = 1231 const GEN1_BEDROOM_STAIRS := Vector2i(7, 1) const GEN1_HOUSE_DOOR := Vector2i(2, 7) ## `PalletTownDefaultScript` stops the player at `wYCoord == 1`, and Yellow's at 0. @@ -9473,6 +9579,7 @@ func _gen1_story_path(data: GameData) -> Dictionary: _gen1_mt_moon_leg, _gen1_cerulean_rival_leg, _gen1_bills_house_leg, _gen1_cerulean_gym_leg, _gen1_cerulean_thief_leg, _gen1_ss_anne_leg, _gen1_vermilion_gym_leg, _gen1_rock_tunnel_leg, _gen1_celadon_gym_leg, + _gen1_rocket_hideout_leg, _gen1_pokemon_tower_leg, ] for leg: Callable in legs: var walked: Dictionary = leg.call(world, save, random, data, path) @@ -10283,3 +10390,198 @@ func _gen1_celadon_gym_leg( if not world.state.is_engine_flag_active(Gen2WorldState.gen1_badge_flag(GEN1_BIT_RAINBOWBADGE)): return {"ok": false, "path": path, "reason": "celadon_gym_erika: RAINBOWBADGE is clear"} return _gen1_warp_legs(path, world, save, random, data, [[GEN1_CELADON_CITY, "celadon_gym_exit"]]) + + +## The Game Corner's poster and the hideout's four floors down to the LIFT KEY. +func _gen1_rocket_hideout_leg( + world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, + path: Array +) -> Dictionary: + var stepped: Dictionary = _gen1_step(path, "celadon_cut_tree_again", world, _gen1_cut( + world, GEN1_CELADON_TREE_INSIDE, Gen2WorldSprite.FACING_UP, save, random, data + )) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_GAME_CORNER, "game_corner_entry", GEN1_GAME_CORNER_DOOR], + ]) + if not bool(stepped["ok"]): + return stepped + var guard: Dictionary = _gen1_talk_to(world, GEN1_GAME_CORNER_ROCKET, save, random, data) + stepped = _gen1_step(path, "game_corner_rocket", world, guard, {"party": _party_species(save)}) + if not bool(stepped["ok"]): + return stepped + var poster: Dictionary = _gen1_talk( + world, GEN1_GAME_CORNER_ROCKET, Gen2WorldSprite.FACING_UP, save, random, data + ) + stepped = _gen1_step(path, "game_corner_poster", world, poster) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_flag_leg( + path, "game_corner_poster", world, GEN1_EVENT_FOUND_ROCKET_HIDEOUT, "EVENT_FOUND_ROCKET_HIDEOUT" + ) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_ROCKET_HIDEOUT_B1F, "game_corner_stairs", GEN1_GAME_CORNER_STAIRS], + [GEN1_ROCKET_HIDEOUT_B2F, "hideout_b1f_to_b2f", GEN1_HIDEOUT_B1F_STAIRS], + [GEN1_ROCKET_HIDEOUT_B3F, "hideout_b2f_to_b3f", GEN1_HIDEOUT_B2F_STAIRS], + [GEN1_ROCKET_HIDEOUT_B4F, "hideout_b3f_to_b4f", GEN1_HIDEOUT_B3F_STAIRS], + ]) + if not bool(stepped["ok"]): + return stepped + var rocket: Dictionary = _gen1_talk_to(world, GEN1_HIDEOUT_ROCKET_3, save, random, data) + stepped = _gen1_step(path, "hideout_b4f_rocket_3", world, rocket, {"party": _party_species(save)}) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_flag_leg( + path, "hideout_b4f_rocket_3", world, GEN1_EVENT_ROCKET_DROPPED_LIFT_KEY, "EVENT_ROCKET_DROPPED_LIFT_KEY" + ) + if not bool(stepped["ok"]): + return stepped + var key: Dictionary = _gen1_talk(world, GEN1_BELOW_LIFT_KEY, Gen2WorldSprite.FACING_UP, save, random, data) + stepped = _gen1_step(path, "hideout_lift_key", world, key, {"items": _named_items(data, world.state.items())}) + if not bool(stepped["ok"]): + return stepped + if int(world.state.items().get(GEN1_LIFT_KEY, 0)) != 1: + return {"ok": false, "path": path, "reason": "hideout_lift_key: the bag holds %s" % [ + _named_items(data, world.state.items())]} + return _gen1_giovanni_leg(world, save, random, data, path) + + +## The car up to Giovanni and the SILPH SCOPE he leaves behind. +func _gen1_giovanni_leg( + world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, + path: Array +) -> Dictionary: + var stepped: Dictionary = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_ROCKET_HIDEOUT_B3F, "hideout_b4f_to_b3f", GEN1_HIDEOUT_B4F_STAIRS], + [GEN1_ROCKET_HIDEOUT_B2F, "hideout_b3f_to_b2f", GEN1_HIDEOUT_B3F_UP], + [GEN1_ROCKET_HIDEOUT_ELEVATOR, "hideout_b2f_elevator", GEN1_HIDEOUT_B2F_ELEVATOR], + ]) + if not bool(stepped["ok"]): + return stepped + _gen1_elevator_floor = GEN1_ROCKET_HIDEOUT_B4F + var panel: Dictionary = _gen1_talk(world, GEN1_HIDEOUT_CAR_PANEL, Gen2WorldSprite.FACING_UP, save, random, data) + stepped = _gen1_step(path, "hideout_elevator_panel", world, panel) + if not bool(stepped["ok"]): + return stepped + if _gen1_elevator_floor >= 0: + return {"ok": false, "path": path, "reason": "hideout_elevator_panel: the car offered no floor"} + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_ROCKET_HIDEOUT_B4F, "hideout_elevator_to_b4f", GEN1_HIDEOUT_CAR_DOOR], + ]) + if not bool(stepped["ok"]): + return stepped + for rocket_cell: Vector2i in GEN1_HIDEOUT_DOOR_ROCKETS: + var fought: Dictionary = _gen1_talk_to(world, rocket_cell, save, random, data) + stepped = _gen1_step(path, "hideout_door_rocket_%d" % rocket_cell.x, world, fought, { + "party": _party_species(save)}) + if not bool(stepped["ok"]): + return stepped + var giovanni: Dictionary = _gen1_talk_to(world, GEN1_HIDEOUT_GIOVANNI, save, random, data) + stepped = _gen1_step(path, "hideout_giovanni", world, giovanni, {"party": _party_species(save)}) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_flag_leg( + path, "hideout_giovanni", world, GEN1_EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI, + "EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI" + ) + if not bool(stepped["ok"]): + return stepped + var scope: Dictionary = _gen1_talk( + world, GEN1_BELOW_SILPH_SCOPE, Gen2WorldSprite.FACING_UP, save, random, data + ) + stepped = _gen1_step(path, "hideout_silph_scope", world, scope, { + "items": _named_items(data, world.state.items())}) + if not bool(stepped["ok"]): + return stepped + if int(world.state.items().get(GEN1_SILPH_SCOPE, 0)) != 1: + return {"ok": false, "path": path, "reason": "hideout_silph_scope: the bag holds %s" % [ + _named_items(data, world.state.items())]} + return {"ok": true} + + +## The car back up, the tower floor by floor, and Mr. Fuji's POKé FLUTE. +func _gen1_pokemon_tower_leg( + world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, + path: Array +) -> Dictionary: + var stepped: Dictionary = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_ROCKET_HIDEOUT_ELEVATOR, "hideout_b4f_elevator", GEN1_HIDEOUT_B4F_ELEVATOR], + ]) + if not bool(stepped["ok"]): + return stepped + _gen1_elevator_floor = GEN1_ROCKET_HIDEOUT_B1F + var panel: Dictionary = _gen1_talk(world, GEN1_HIDEOUT_CAR_PANEL, Gen2WorldSprite.FACING_UP, save, random, data) + stepped = _gen1_step(path, "hideout_elevator_panel_up", world, panel) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_ROCKET_HIDEOUT_B1F, "hideout_elevator_to_b1f", GEN1_HIDEOUT_CAR_DOOR], + ]) + if not bool(stepped["ok"]): + return stepped + var door_rocket: Dictionary = _gen1_talk_to(world, GEN1_HIDEOUT_B1F_DOOR_ROCKET, save, random, data) + stepped = _gen1_step(path, "hideout_b1f_door_rocket", world, door_rocket, {"party": _party_species(save)}) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_GAME_CORNER, "hideout_b1f_to_game_corner", GEN1_HIDEOUT_B1F_ELEVATOR_STAIRS], + [GEN1_CELADON_CITY, "game_corner_exit"], + ["east", GEN1_ROUTE_7, "celadon_to_route_7"], + [GEN1_UNDERGROUND_PATH_ROUTE_7, "route_7_to_underground"], + [GEN1_UNDERGROUND_PATH_WEST_EAST, "underground_west_entrance"], + [GEN1_UNDERGROUND_PATH_ROUTE_8, "underground_east_exit"], + [GEN1_ROUTE_8, "underground_to_route_8"], + ["east", GEN1_LAVENDER_TOWN, "route_8_to_lavender"], + [GEN1_POKEMON_TOWER_1F, "lavender_to_tower", GEN1_LAVENDER_TOWER_DOOR], + [GEN1_POKEMON_TOWER_2F, "tower_1f_to_2f", GEN1_TOWER_EAST_STAIRS], + [GEN1_POKEMON_TOWER_3F, "tower_2f_to_3f", GEN1_TOWER_WEST_STAIRS], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_flag_leg( + path, "tower_2f_to_3f", world, GEN1_EVENT_BEAT_POKEMON_TOWER_RIVAL, "EVENT_BEAT_POKEMON_TOWER_RIVAL" + ) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_POKEMON_TOWER_4F, "tower_3f_to_4f", GEN1_TOWER_EAST_STAIRS], + [GEN1_POKEMON_TOWER_5F, "tower_4f_to_5f", GEN1_TOWER_WEST_STAIRS], + [GEN1_POKEMON_TOWER_6F, "tower_5f_to_6f", GEN1_TOWER_EAST_STAIRS], + ]) + if not bool(stepped["ok"]): + return stepped + var candy: Dictionary = _gen1_talk(world, GEN1_ABOVE_RARE_CANDY, Gen2WorldSprite.FACING_DOWN, save, random, data) + stepped = _gen1_step(path, "tower_6f_rare_candy", world, candy, {"items": _named_items(data, world.state.items())}) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_POKEMON_TOWER_7F, "tower_6f_marowak_to_7f", GEN1_TOWER_7F_STAIRS], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_flag_leg(path, "tower_6f_marowak_to_7f", world, GEN1_EVENT_BEAT_GHOST_MAROWAK, "EVENT_BEAT_GHOST_MAROWAK") + if not bool(stepped["ok"]): + return stepped + var fuji: Dictionary = _gen1_talk_to(world, GEN1_MR_FUJI, save, random, data) + stepped = _gen1_step(path, "tower_7f_mr_fuji", world, fuji, {"party": _party_species(save)}) + if not bool(stepped["ok"]): + return stepped + if world.map_id() != Vector2i(0, GEN1_MR_FUJIS_HOUSE): + return {"ok": false, "path": path, "reason": "tower_7f_mr_fuji: Fuji left the player on %s" % [_map_value(world)]} + var flute: Dictionary = _gen1_talk(world, GEN1_BELOW_MR_FUJI_HOME, Gen2WorldSprite.FACING_UP, save, random, data) + stepped = _gen1_step(path, "mr_fujis_house_flute", world, flute, {"items": _named_items(data, world.state.items())}) + if not bool(stepped["ok"]): + return stepped + for flag: Array in [ + [GEN1_EVENT_RESCUED_MR_FUJI, "EVENT_RESCUED_MR_FUJI"], [GEN1_EVENT_GOT_POKE_FLUTE, "EVENT_GOT_POKE_FLUTE"], + ]: + stepped = _gen1_flag_leg(path, "mr_fujis_house_flute", world, int(flag[0]), String(flag[1])) + if not bool(stepped["ok"]): + return stepped + if int(world.state.items().get(GEN1_POKE_FLUTE, 0)) != 1: + return {"ok": false, "path": path, "reason": "mr_fujis_house_flute: the bag holds %s" % [ + _named_items(data, world.state.items())]} + return {"ok": true} From 105953482606886392fb5e1612195ac305887a24 Mon Sep 17 00:00:00 2001 From: Decrypt <90518536+Decryptu@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:25:25 +0200 Subject: [PATCH 4/8] =?UTF-8?q?Walk=20Red,=20Blue=20and=20Yellow=20from=20?= =?UTF-8?q?the=20POK=C3=A9=20FLUTE=20to=20Koga?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route 12's SNORLAX wakes to the flute and is fought off the map script, the two gates on Route 12 and Route 15 are crossed, Route 13 is left at row 8 (its rows 4 and 6 end at Bird Keepers standing in one-cell corridors, so a crossing may name its edge cell now), and Fuchsia's gym gives the fifth badge on all three cartridges. --- README.md | 2 +- autoload/diagnostics.gd | 7 --- game/world/battle_tower.gd | 6 --- game/world/pokedex.gd | 6 --- tests/unit/test_source_budget.gd | 2 +- tools/preview_world_story.gd | 85 +++++++++++++++++++++++++++++++- 6 files changed, 86 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 57512b44..d6e0b9da 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ The rest are previews and dumps, each driving a real screen or table: ```bash # the full walked route: Johto, the Hall of Fame, every Kanto gym, and Red godot --headless --path . -s res://tools/preview_world_story.gd -- crystal 24 7 2 2 1 none home story -# Red, Blue or Yellow from the bedroom to the POKé FLUTE +# Red, Blue or Yellow from the bedroom to Koga godot --headless --path . -s res://tools/preview_world_story.gd -- red 0 38 3 6 1 none home story ``` diff --git a/autoload/diagnostics.gd b/autoload/diagnostics.gd index ad16f65c..096e9e20 100644 --- a/autoload/diagnostics.gd +++ b/autoload/diagnostics.gd @@ -3,7 +3,6 @@ extends Node ## What a player hands over when something goes wrong: this build, this machine, ## the settings and mods in force, and the engine's own log of the session. -## ## A sink rather than a set of call sites: [Logger] goes in through ## [method OS.add_logger], so every print, warning and runtime error in the game, ## a tool or a mod reaches the report without being routed twice. The file at @@ -62,7 +61,6 @@ static func instance() -> Gen2Diagnostics: ## Records one line of context. Safe before the autoload exists and in a tool ## run, so a caller never guards the call itself. -## ## Goes through `print`, which is what puts it in the engine's log file next to ## the errors it explains; the sink below picks it up on the way past. static func note(topic: String, message: String) -> void: @@ -92,7 +90,6 @@ func _ready() -> void: ## Printed rather than written, so it is near the top of the engine's own log ## file: a crash log is then self-describing whether or not the player ever ## reaches the launcher again. -## ## A player's launch only, for the reason [method trace] gives: a check or a ## tool is read for the answer it prints, and a header no one asked for is in ## the way of it. @@ -209,7 +206,6 @@ func report() -> String: ## Writes the report and every kept log file into one `.zip` under ## [param folder], and answers the path it wrote. -## ## A zip rather than the log itself because the useful thing is the whole set: ## the session that crashed is usually the file *before* the one this launch is ## writing. An empty [param folder] takes the platform's downloads directory, @@ -285,7 +281,6 @@ func log_files(directory: String = DIRECTORY) -> PackedStringArray: ## Drops log files in [param directory] past the count, the age or the total ## size this keeps, whichever bites first, oldest first. -## ## The engine's own rotation counts files at startup and nothing else, so a ## single session that logged for a week, or a build that once wrote under ## another name, would otherwise sit in the player's data directory for good. @@ -339,7 +334,6 @@ func record(level: String, message: String) -> void: ## Reads the previous session's marker and raises this one's. -## ## Only a player's own launch writes it. A headless check or a `-s` tool that ## the wall-clock cap kills never reaches [method _exit_tree], so letting those ## write the marker would report a crash to the player at the next launch, and @@ -510,7 +504,6 @@ static func _video_adapter() -> String: ## The sink itself, kept beside the autoload rather than in a file of its own: ## it is four lines and it has no other caller. -## ## Nothing here prints. A logger that raised a message of its own would be ## handed it straight back. class Gen2DiagnosticsSink extends Logger: diff --git a/game/world/battle_tower.gd b/game/world/battle_tower.gd index 2b54d82c..dcb83400 100644 --- a/game/world/battle_tower.gd +++ b/game/world/battle_tower.gd @@ -4,7 +4,6 @@ extends RefCounted ## `SECTION "SRAM Battle Tower"` and the routines that read it: the challenge's ## own state, the seven trainers it has already sampled, and the levels, rules ## and rewards `engine/events/battle_tower/` decides from them. -## ## Scene free. The world screen owns the two menus and the battle; this record ## owns the SRAM section and every answer `BattleTowerAction`, `LoadOpponentTrainerAndPokemon` ## and `_CheckForBattleTowerRules` give from it. @@ -188,7 +187,6 @@ static func group_level(group: int) -> int: ## `BattleTower_LevelCheck`, which fails on the first party member above the ## chosen group's level. -1 when the party is inside it. -## ## [param party] is the read-only party mirror the script runner carries: ## `species`, `levels`, `held_items` and `eggs`, one entry per slot. static func level_check(party: Dictionary, group: int) -> int: @@ -219,7 +217,6 @@ static func ubers_check(party: Dictionary, group: int) -> int: ## `_CheckForBattleTowerRules`' four checks in its own order, each named by the ## box it prints. An empty Array is a party that may enter. -## ## `BattleTower_ExecuteJumptable` runs every check rather than stopping at the ## first, so a party can fail more than one and the receptionist says so once ## per failure. @@ -288,7 +285,6 @@ func load_opponent(data: GameData, random: RandomNumberGenerator) -> Dictionary: ## The `.resample` loop: `maskbits BATTLETOWER_NUM_UNIQUE_TRAINERS` is seven ## bits, so the roll is 0 to 127 and anything past the table is drawn again, as ## is any index already in `sBTTrainers`. -## ## Crystal 1.0 masks with `BATTLETOWER_NUM_UNIQUE_MON` instead and can only ever ## reach the first 21; the pinned dumps are 1.1, so this is the 1.1 branch. func _sample_trainer(random: RandomNumberGenerator) -> int: @@ -436,7 +432,6 @@ func choose_reward(random: RandomNumberGenerator) -> int: ## `BattleTower_GiveReward`: the stored item, or POTION when the pack has no room ## for five more of it. [param pack] is the item pocket as ## [code]{ item: quantity }[/code]. -## ## The source's own shape, which is not "is there room": a pack under `MAX_ITEMS` ## always has room for a new row, and a full one only does when it already ## carries the reward with fewer than `MAX_ITEM_STACK - 5 + 1` of it. @@ -452,7 +447,6 @@ func reward_for(pack: Dictionary) -> int: ## `BattleTowerAction`'s jumptable, as the value it leaves in `wScriptVar`. ## An action that writes nothing there answers -1, which the runner leaves the ## variable alone for. -## ## The mobile rows are no-ops with a reason: each writes a byte in SRAM bank 5 ## that only the mobile adapter's own routines read, and none of them reaches a ## script the player can talk to. diff --git a/game/world/pokedex.gd b/game/world/pokedex.gd index 2df7083e..3cb290db 100644 --- a/game/world/pokedex.gd +++ b/game/world/pokedex.gd @@ -5,7 +5,6 @@ extends RefCounted ## `wPokedexDataStart`..`wPokedexDataEnd` does: the mode, the species order that ## mode built, how far the listing runs, and the cursor and scroll into it. A ## screen draws [method rows] and feeds buttons back in. -## ## The orderings come from the cache ([method GameData.dex_order_new] and ## [method GameData.dex_order_alpha]); DEXMODE_OLD has no table, `.OldMode` ## counting from 1. Seen and caught come from [Gen2WorldState]. @@ -402,7 +401,6 @@ func _move_up_one_page() -> bool: ## `Pokedex_ListingMoveDownOnePage`, which always reports a change. -## ## The source adds two pages to the offset in one byte and treats the carry as ## "near the bottom", so an offset that would overflow lands on the last page ## exactly as one that runs past `wDexListingEnd` does. The wrap is kept because @@ -437,7 +435,6 @@ func toggle_page() -> void: ## `Pokedex_NextOrPreviousDexEntry`: moves until it lands on a seen species, and ## puts the cursor and scroll back if it runs out of listing first. -## ## Answers whether it moved. A move re-enters the entry screen at page 1, which ## is `Pokedex_ReinitDexEntryScreen`. func step_entry(button: int) -> bool: @@ -460,7 +457,6 @@ func step_entry(button: int) -> bool: ## The selected species' entry, as `DisplayDexEntry` prints it: ## { species, name, number, category, caught, height, weight, page, text }. -## ## The name, the category and the number are printed whether or not the species ## has been caught; the caught check sits after them and gates the measurements ## and the description. A zero height or weight is left blank rather than printed @@ -542,7 +538,6 @@ static func mode_rows(with_unown: bool = false) -> Array: ## `.ChangeMode`: choosing the mode already in use changes nothing, and choosing ## another reorders the listing and puts the cursor back at the top before ## seeking `wPrevDexEntry` again. -## ## Answers whether the mode changed, which is what decides whether the screen ## shows [constant CHANGING_MODES_TEXT]. func change_mode(next_mode: int) -> bool: @@ -587,7 +582,6 @@ func search_type_string(value: int) -> String: ## `Pokedex_UpdateSearchMonType`, which reads left and right on the two type rows ## only and answers whether the value changed. -## ## The two rows wrap differently, and deliberately: the first row runs 1 to ## NUM_TYPES and can never be empty, while the second wraps through ## [constant SEARCH_TYPE_NONE] as well, which is the only way to search on one diff --git a/tests/unit/test_source_budget.gd b/tests/unit/test_source_budget.gd index ad8855b3..79a963d7 100644 --- a/tests/unit/test_source_budget.gd +++ b/tests/unit/test_source_budget.gd @@ -21,7 +21,7 @@ const MAX_COMMENT_BLOCK: int = 8 ## the shared code, the largest of them 152 the region map, 150 the battle ## animation engine, 140 the transition, 131 the field moves, 130 the Pokedex, ## 108 the three PCs, 90 the Safari Zone, 52 the menus a row draws. -const MAX_COMMENT_LINES: int = 41967 +const MAX_COMMENT_LINES: int = 41950 ## What no comment block ever ends on. A pass that meets the ceiling above trims ## the second line of a two-line block and leaves the first mid-sentence, which diff --git a/tools/preview_world_story.gd b/tools/preview_world_story.gd index 542919ad..a5fe2c0f 100644 --- a/tools/preview_world_story.gd +++ b/tools/preview_world_story.gd @@ -9499,6 +9499,28 @@ const GEN1_EVENT_BEAT_POKEMON_TOWER_RIVAL: int = 239 const GEN1_EVENT_BEAT_GHOST_MAROWAK: int = 271 const GEN1_EVENT_GOT_POKE_FLUTE: int = 296 const GEN1_EVENT_RESCUED_MR_FUJI: int = 1231 +const GEN1_ROUTE_12: int = 23 +const GEN1_ROUTE_13: int = 24 +const GEN1_ROUTE_14: int = 25 +const GEN1_ROUTE_15: int = 26 +const GEN1_FUCHSIA_CITY: int = 7 +const GEN1_FUCHSIA_GYM: int = 157 +const GEN1_ROUTE_12_GATE_1F: int = 87 +## Route 13's rows 4 and 6 end at Bird Keepers; row 8 goes through. +const GEN1_ROUTE_13_WEST_EDGE := Vector2i(0, 8) +const GEN1_ROUTE_15_GATE_1F: int = 184 +const GEN1_ROUTE_12_GATE_DOOR := Vector2i(10, 15) +const GEN1_ROUTE_12_GATE_SOUTH := Vector2i(4, 7) +const GEN1_ROUTE_15_GATE_DOOR := Vector2i(14, 8) +const GEN1_ROUTE_15_GATE_WEST := Vector2i(0, 4) +const GEN1_ABOVE_SNORLAX := Vector2i(10, 61) +const GEN1_FUCHSIA_GYM_DOOR := Vector2i(5, 27) +const GEN1_KOGA := Vector2i(4, 10) +const GEN1_EVENT_FIGHT_ROUTE12_SNORLAX: int = 1166 +const GEN1_EVENT_BEAT_ROUTE12_SNORLAX: int = 1167 +const GEN1_EVENT_GOT_TM06: int = 600 +const GEN1_EVENT_BEAT_KOGA: int = 601 +const GEN1_BIT_SOULBADGE: int = 4 const GEN1_BEDROOM_STAIRS := Vector2i(7, 1) const GEN1_HOUSE_DOOR := Vector2i(2, 7) ## `PalletTownDefaultScript` stops the player at `wYCoord == 1`, and Yellow's at 0. @@ -9579,7 +9601,7 @@ func _gen1_story_path(data: GameData) -> Dictionary: _gen1_mt_moon_leg, _gen1_cerulean_rival_leg, _gen1_bills_house_leg, _gen1_cerulean_gym_leg, _gen1_cerulean_thief_leg, _gen1_ss_anne_leg, _gen1_vermilion_gym_leg, _gen1_rock_tunnel_leg, _gen1_celadon_gym_leg, - _gen1_rocket_hideout_leg, _gen1_pokemon_tower_leg, + _gen1_rocket_hideout_leg, _gen1_pokemon_tower_leg, _gen1_fuchsia_gym_leg, ] for leg: Callable in legs: var walked: Dictionary = leg.call(world, save, random, data, path) @@ -9893,11 +9915,18 @@ func _gen1_parcel_leg( ]) +## A row is [direction, map, step], with the edge cell to cross at fourth. func _gen1_crossings( path: Array, world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, legs: Array ) -> Dictionary: for leg: Array in legs: + if leg.size() > 3: + var stepped_to: Dictionary = _gen1_step( + path, "%s_edge" % leg[2], world, _gen1_walk(world, leg[3], save, random, data) + ) + if not bool(stepped_to["ok"]): + return stepped_to var crossed: Dictionary = _gen1_cross(world, String(leg[0]), int(leg[1]), save, random, data) var stepped: Dictionary = _gen1_step(path, String(leg[2]), world, crossed) if not bool(stepped["ok"]): @@ -10585,3 +10614,57 @@ func _gen1_pokemon_tower_leg( return {"ok": false, "path": path, "reason": "mr_fujis_house_flute: the bag holds %s" % [ _named_items(data, world.state.items())]} return {"ok": true} + + +func _gen1_fuchsia_gym_leg( + world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, + path: Array +) -> Dictionary: + var stepped: Dictionary = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_LAVENDER_TOWN, "mr_fujis_house_exit"], + ["south", GEN1_ROUTE_12, "lavender_to_route_12"], + [GEN1_ROUTE_12_GATE_1F, "route_12_gate", GEN1_ROUTE_12_GATE_DOOR], + [GEN1_ROUTE_12, "route_12_gate_south", GEN1_ROUTE_12_GATE_SOUTH], + ]) + if not bool(stepped["ok"]): + return stepped + var beside: Dictionary = _gen1_walk(world, GEN1_ABOVE_SNORLAX, save, random, data) + if bool(beside.get("ok", false)): + var flute: Dictionary = world.poke_flute_request() + if not bool(flute.get("woke", false)): + beside = {"ok": false, "reason": "the flute woke nothing: %s" % [flute], "runs": beside["runs"]} + else: + beside = _gen1_settle(world, save, random, data, world.dispatch_sight_events()) + stepped = _gen1_step(path, "route_12_snorlax", world, beside, {"party": _party_species(save)}) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_flag_leg(path, "route_12_snorlax", world, GEN1_EVENT_BEAT_ROUTE12_SNORLAX, "EVENT_BEAT_ROUTE12_SNORLAX") + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + ["south", GEN1_ROUTE_13, "route_12_to_route_13"], + ["west", GEN1_ROUTE_14, "route_13_to_route_14", GEN1_ROUTE_13_WEST_EDGE], + ["west", GEN1_ROUTE_15, "route_14_to_route_15"], + [GEN1_ROUTE_15_GATE_1F, "route_15_gate", GEN1_ROUTE_15_GATE_DOOR], + [GEN1_ROUTE_15, "route_15_gate_west", GEN1_ROUTE_15_GATE_WEST], + ["west", GEN1_FUCHSIA_CITY, "route_15_to_fuchsia"], + [GEN1_FUCHSIA_GYM, "fuchsia_gym_entry", GEN1_FUCHSIA_GYM_DOOR], + ]) + if not bool(stepped["ok"]): + return stepped + var koga: Dictionary = _gen1_talk_to(world, GEN1_KOGA, save, random, data) + stepped = _gen1_step(path, "fuchsia_gym_koga", world, koga, { + "party": _party_species(save), "items": _named_items(data, world.state.items()), + "badges": world.state.badge_count(), + }) + if not bool(stepped["ok"]): + return stepped + for flag: Array in [ + [GEN1_EVENT_BEAT_KOGA, "EVENT_BEAT_KOGA"], [GEN1_EVENT_GOT_TM06, "EVENT_GOT_TM06"], + ]: + stepped = _gen1_flag_leg(path, "fuchsia_gym_koga", world, int(flag[0]), String(flag[1])) + if not bool(stepped["ok"]): + return stepped + if not world.state.is_engine_flag_active(Gen2WorldState.gen1_badge_flag(GEN1_BIT_SOULBADGE)): + return {"ok": false, "path": path, "reason": "fuchsia_gym_koga: SOULBADGE is clear"} + return _gen1_warp_legs(path, world, save, random, data, [[GEN1_FUCHSIA_CITY, "fuchsia_gym_exit"]]) From 81c80e8c6bb346473d70c1d25c7cc2f6de8461e9 Mon Sep 17 00:00:00 2001 From: Decrypt <90518536+Decryptu@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:09 +0200 Subject: [PATCH 5/8] Walk Red, Blue and Yellow through the Safari Zone to HM03 and HM04 The gate's fee, areas 1, 2 and 3 to the secret house's HM03 and the GOLD TEETH, back the same way, and the warden's HM04, on all three cartridges. The Safari centre's tree lines split it three ways, so the entrance reaches the east door alone. `wSavedCoordIndex` was written when a row was resolved rather than when its step was spent, and a `YesNoChoice` resolves both sides before the answer, so the gate's "Leaving early?" always landed on the NO side's state and asked again for ever. The run keeps its own copy now and the world takes the step; `gen1_walk.gd` pins the early leave. --- README.md | 2 +- game/battle/damage.gd | 5 -- game/data/world_catalog.gd | 5 -- game/world/world_api.gd | 22 +++++-- tests/unit/test_source_budget.gd | 2 +- tools/checks/gen1_walk.gd | 39 ++++++++++++ tools/preview_world_story.gd | 104 +++++++++++++++++++++++++++++++ 7 files changed, 161 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index d6e0b9da..7a59b675 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ The rest are previews and dumps, each driving a real screen or table: ```bash # the full walked route: Johto, the Hall of Fame, every Kanto gym, and Red godot --headless --path . -s res://tools/preview_world_story.gd -- crystal 24 7 2 2 1 none home story -# Red, Blue or Yellow from the bedroom to Koga +# Red, Blue or Yellow from the bedroom to the Safari Zone and HM04 godot --headless --path . -s res://tools/preview_world_story.gd -- red 0 38 3 6 1 none home story ``` diff --git a/game/battle/damage.gd b/game/battle/damage.gd index 503a9fab..14b01065 100644 --- a/game/battle/damage.gd +++ b/game/battle/damage.gd @@ -123,7 +123,6 @@ static func damage_stats( ## `BattleCommand_DamageCalc`: Selfdestruct's halved defense, the formula, the ## type-boosting item, the critical multiplier, the cap and the minimum. -## ## Its `ret z` on a power of zero is why a status move reaches ## [method stab_damage] with no damage rather than the minimum two. ## [param level] is -1 for the attacker's own; `BattleCommand_BeatUp` is the one @@ -171,7 +170,6 @@ static func damage_calc( ## over whatever [method damage_calc] left. It runs for a powerless move too, and ## has to: `DoPoison` and `DoParalyze` carry `stab` and no `damagecalc`, which is ## how Thunder Wave learns it does nothing to a Ground type. -## ## Returns { damage, stab, immune, effectiveness }. [param foresight] is ## `SUBSTATUS_IDENTIFIED`, which drops the rows past the chart's `-2` marker. static func stab_damage( @@ -456,7 +454,6 @@ const CONSTANT_DAMAGE_EFFECTS: Array[int] = [ ## and by `AIDamageCalc`, which routes the same four effects here rather than ## through the formula. [param defender] is whoever the hit lands on, which is ## the only mon Super Fang reads. -## ## A null [param rng] is a prediction rather than a hit, and answers Psywave with ## the top of its range the way [constant MAX_VARIATION] does for the formula. static func constant_damage( @@ -489,7 +486,6 @@ static func psywave_damage(level: int, rng: RandomNumberGenerator) -> int: ## The split is by type, not by move: below Fire is physical and Fire up ## special, which is why Hyper Beam is physical and Bite special. -## ## A type past the cartridge's chart is a mod's own and carries the choice on its ## row instead, since there is no number to compare it against. Only such a ## number reaches the overlay, so a cartridge battle pays one comparison. @@ -516,7 +512,6 @@ static func _attack_stat( ## The defending stat, doubled by the defender's screen. Metal Powder is not ## here: `DittoMetalPowder` runs past `TruncateHL_BC`, which is ## [method metal_powder_pair]. -## ## `PlayerAttackDamage` doubles before `CheckDamageStatsCritical`, and a critical ## reaching `.thickclub`'s no-carry branch reloads the unmodified stat over it, ## so a critical that ignores the defender's stages ignores its screen too. diff --git a/game/data/world_catalog.gd b/game/data/world_catalog.gd index a51476da..1843e35b 100644 --- a/game/data/world_catalog.gd +++ b/game/data/world_catalog.gd @@ -6,7 +6,6 @@ extends RefCounted ## trades, prizes, ground items, badges and shops. The host owns the decoding and ## the mod owns the placement, so a randomizer needs no private copy of cartridge ## semantics. A patch changes a FIELD of a row and never the script behind it. -## ## Nothing is imported for this: every row is derived from the cache and written ## beside it as a sidecar, so an absent or stale one is rebuilt rather than bumped. @@ -131,7 +130,6 @@ static func build_reporting( ## The scan's result, for the sidecar. Ids are dictionary keys, so they go out ## as decimal strings and come back as ints; every one is well under the 2^53 ## a JSON number carries exactly. -## ## The lazy answers ([member _item_sources], [member _field_hms]) are not here: ## both are derived from these rows in a millisecond and would only be a second ## copy to keep in step. @@ -187,7 +185,6 @@ static func from_dict(data: GameData, source: Variant) -> Gen2WorldCatalog: ## and a `kind` comes back as floats and a String. A reader compares these with ## `==` against typed literals, so the shapes have to be restored rather than ## coerced at every site. -## ## Whole floats become ints: every number a row carries is an id, a bank, an ## address, an item, a quantity or a price, and none of them is fractional. The ## two String fields that are StringNames name themselves. @@ -278,7 +275,6 @@ func size() -> int: ## The site a command at [param bank]:[param address] belongs to but is not ## itself, as `{id, role}`, or empty. `role` is `picture` for a starter's ## `pokepic` and `price` for a prize's `checkcoins` or `takecoins`. -## ## This is what makes a patched field effective at the whole TRANSACTION rather ## than at one command of it: the ball that shows a Bellsprout hands over a ## Bellsprout, and a prize the mod priced at 500 is refused at 499 coins and @@ -806,7 +802,6 @@ func _store(row: Dictionary) -> void: ## it checked and the items it asked for. The decoded graph fact a placement ## needs, and no more than a fact: it does not say the site is unreachable ## without them, only that the cartridge looked. -## ## Read in source order up to the site rather than over the whole script, since a ## `checkevent` after a `givepoke` guards something else. func _requirements(commands: Array, at: int, crystal: bool) -> Array: diff --git a/game/world/world_api.gd b/game/world/world_api.gd index 76556362..ebc59319 100644 --- a/game/world/world_api.gd +++ b/game/world/world_api.gd @@ -4388,12 +4388,12 @@ func _gen1_node_object_facing( ## The store `CallFunctionInTable` dispatches on next frame; a map with no ## dispatch has no byte for `wCurMapScript` to be copied into. -func _gen1_node_set_map_script(node: Dictionary, steps: Array, _run: Dictionary) -> bool: +func _gen1_node_set_map_script(node: Dictionary, steps: Array, run: Dictionary) -> bool: var byte: int = int(node["byte"]) if byte < 0: return true ## `wNextSafariZoneGateScript`: the state the walk before it is to land on. - var value: int = _gen1_saved_coord_index if node.has("from") else int(node["value"]) + var value: int = _gen1_run_saved_index(run) if node.has("from") else int(node["value"]) steps.append({"type": &"map_script", "byte": byte, "value": value}) return true @@ -4524,7 +4524,7 @@ func _gen1_node_saved_coord_index( node: Dictionary, steps: Array, run: Dictionary ) -> bool: return _gen1_resolve_side( - node, _gen1_index_matches(node, _gen1_saved_coord_index), steps, run + node, _gen1_index_matches(node, _gen1_run_saved_index(run)), steps, run ) @@ -4533,15 +4533,22 @@ func _gen1_index_matches(node: Dictionary, index: int) -> bool: return index < value if String(node["test"]) == "below" else index == value -## The store, whose -1 is `wCoordIndex` itself rather than a constant. +## The store, whose -1 is `wCoordIndex` itself. The run keeps the copy the rest +## of the row reads and the world takes it when spent: both sides of the gate's +## "Leaving early?" resolve before the answer, and NO's 5 had overwritten YES's 0. func _gen1_node_save_coord_index( - node: Dictionary, _steps: Array, run: Dictionary + node: Dictionary, steps: Array, run: Dictionary ) -> bool: var value: int = int(node["value"]) - _gen1_saved_coord_index = int(run.get("coord_index", 0)) if value < 0 else value + run["saved_coord_index"] = int(run.get("coord_index", 0)) if value < 0 else value + steps.append({"type": &"saved_coord_index", "value": int(run["saved_coord_index"])}) return true +func _gen1_run_saved_index(run: Dictionary) -> int: + return int(run.get("saved_coord_index", _gen1_saved_coord_index)) + + func _gen1_node_player_facing(node: Dictionary, steps: Array, _run: Dictionary) -> bool: steps.append({ "type": &"player_facing", @@ -6413,6 +6420,9 @@ func _gen1_written(step: Dictionary, events: Array) -> bool: &"map_script": state.set_gen1_map_script(int(step["byte"]), int(step["value"])) return true + &"saved_coord_index": + _gen1_saved_coord_index = int(step["value"]) + return true &"last_map": _gen1_last_map = int(step["map"]) return true diff --git a/tests/unit/test_source_budget.gd b/tests/unit/test_source_budget.gd index 79a963d7..bea9805f 100644 --- a/tests/unit/test_source_budget.gd +++ b/tests/unit/test_source_budget.gd @@ -21,7 +21,7 @@ const MAX_COMMENT_BLOCK: int = 8 ## the shared code, the largest of them 152 the region map, 150 the battle ## animation engine, 140 the transition, 131 the field moves, 130 the Pokedex, ## 108 the three PCs, 90 the Safari Zone, 52 the menus a row draws. -const MAX_COMMENT_LINES: int = 41950 +const MAX_COMMENT_LINES: int = 41947 ## What no comment block ever ends on. A pass that meets the ceiling above trims ## the second line of a two-line block and leaves the first mid-sentence, which diff --git a/tools/checks/gen1_walk.gd b/tools/checks/gen1_walk.gd index cfe73466..cb69372d 100644 --- a/tools/checks/gen1_walk.gd +++ b/tools/checks/gen1_walk.gd @@ -3556,6 +3556,7 @@ func _check_the_safari_zone() -> void: "the fee left %d." % world.state.money(Gen2WorldMartHost.MONEY_ACCOUNT) ) _check_the_safari_game_ends(world.state) + _check_leaving_early() _check_the_safari_zone_refuses() _r.note("gen1 walk paid the SAFARI ZONE's %d and walked its %d steps out" % [ SAFARI_ADMISSION, Gen1Layout.SAFARI_STEPS, @@ -3594,6 +3595,44 @@ func _check_the_safari_game_ends(state: Gen2WorldState) -> void: "the gate left %d balls." % world.state.safari_balls()) +## `SafariZoneGateSafariZoneWorker1LeavingEarlyText`'s YES: the walk down, both +## events cleared, and `wNextSafariZoneGateScript`'s 0 rather than NO's 5. +const SAFARI_GATE_TOP := Vector2i(3, 0) +const SAFARI_SCRIPT_LEAVE_EARLY_LANDING := Vector2i(3, 3) + + +func _check_leaving_early() -> void: + var state := Gen2WorldState.new() + state.set_event_flag(Gen1Layout.IN_SAFARI_ZONE_EVENT, true) + state.set_safari_balls(Gen1Layout.SAFARI_BALLS) + state.set_safari_steps(Gen1Layout.SAFARI_STEPS) + var world: Gen2WorldAPI = _r.open_world(0, SAFARI_GATE, SAFARI_GATE_TOP, state) + if world == null: + return + world.state.set_gen1_map_script(world.gen1_safari_gate_byte(), SAFARI_SCRIPT_MOVING_UP) + ## `SafariZoneGatePlayerMovingUpScript` hands the question to the next frame. + world.dispatch_sight_events() + world.dispatch_sight_events() + if not _r.check( + StringName(world.pending_script_input().get("type", &"")) == &"choice", + "the gate asked %s of a player leaving early." % [world.pending_script_input()] + ): + return + _safari_answer(world, 0) + var passes: int = 0 + while (world.gen1_player_movement_running() or world.player_step_in_progress()) and passes < SCRIPTED_WALK_PASSES: + world.advance_player_step_pass() + world.dispatch_sight_events() + passes += 1 + world.dispatch_sight_events() + _r.check(world.player_cell == SAFARI_SCRIPT_LEAVE_EARLY_LANDING + and world.state.gen1_map_script(world.gen1_safari_gate_byte()) == 0, + "leaving early left the player on %s at state %d." % [ + world.player_cell, world.state.gen1_map_script(world.gen1_safari_gate_byte())]) + _r.check(not world.gen1_safari_active() and not world.event_flag_active(Gen1Layout.SAFARI_GAME_OVER_EVENT), + "leaving early left the game's events standing.") + + ## Red and Blue walk a short purse back down; Yellow's own two routines hand out ## a reduced admission instead, and nothing at all until the fourth empty visit. func _check_the_safari_zone_refuses() -> void: diff --git a/tools/preview_world_story.gd b/tools/preview_world_story.gd index a5fe2c0f..dbab8340 100644 --- a/tools/preview_world_story.gd +++ b/tools/preview_world_story.gd @@ -9521,6 +9521,35 @@ const GEN1_EVENT_BEAT_ROUTE12_SNORLAX: int = 1167 const GEN1_EVENT_GOT_TM06: int = 600 const GEN1_EVENT_BEAT_KOGA: int = 601 const GEN1_BIT_SOULBADGE: int = 4 +const GEN1_SAFARI_ZONE_GATE: int = 156 +const GEN1_SAFARI_ZONE_NORTH: int = 218 +const GEN1_SAFARI_ZONE_WEST: int = 219 +const GEN1_SAFARI_ZONE_CENTER: int = 220 +const GEN1_SAFARI_ZONE_SECRET_HOUSE: int = 222 +const GEN1_WARDENS_HOUSE: int = 155 +const GEN1_SAFARI_GATE_DOOR := Vector2i(18, 3) +const GEN1_SAFARI_GATE_OFFER := Vector2i(3, 2) +## The centre's tree lines leave the entrance the east door alone. +const GEN1_SAFARI_ZONE_EAST: int = 217 +const GEN1_SAFARI_CENTER_EAST := Vector2i(29, 10) +const GEN1_SAFARI_EAST_NORTH := Vector2i(0, 4) +const GEN1_SAFARI_NORTH_WEST := Vector2i(2, 35) +const GEN1_SAFARI_WEST_NORTH := Vector2i(20, 0) +const GEN1_SAFARI_NORTH_EAST := Vector2i(39, 30) +const GEN1_SAFARI_EAST_CENTER := Vector2i(0, 22) +const GEN1_SAFARI_SECRET_HOUSE_DOOR := Vector2i(3, 3) +const GEN1_BELOW_FISHING_GURU := Vector2i(3, 4) +const GEN1_BELOW_GOLD_TEETH := Vector2i(19, 8) +const GEN1_SAFARI_CENTER_GATE := Vector2i(14, 25) +const GEN1_SAFARI_GATE_SOUTH := Vector2i(3, 5) +const GEN1_WARDENS_HOUSE_DOOR := Vector2i(27, 27) +const GEN1_WARDEN := Vector2i(2, 3) +const GEN1_HM03: int = 0xC6 +const GEN1_HM04: int = 0xC7 +const GEN1_GOLD_TEETH: int = 0x40 +const GEN1_EVENT_GOT_HM03: int = 2176 +const GEN1_EVENT_GOT_HM04: int = 568 +const GEN1_EVENT_GAVE_GOLD_TEETH: int = 569 const GEN1_BEDROOM_STAIRS := Vector2i(7, 1) const GEN1_HOUSE_DOOR := Vector2i(2, 7) ## `PalletTownDefaultScript` stops the player at `wYCoord == 1`, and Yellow's at 0. @@ -9602,6 +9631,7 @@ func _gen1_story_path(data: GameData) -> Dictionary: _gen1_cerulean_gym_leg, _gen1_cerulean_thief_leg, _gen1_ss_anne_leg, _gen1_vermilion_gym_leg, _gen1_rock_tunnel_leg, _gen1_celadon_gym_leg, _gen1_rocket_hideout_leg, _gen1_pokemon_tower_leg, _gen1_fuchsia_gym_leg, + _gen1_safari_zone_leg, ] for leg: Callable in legs: var walked: Dictionary = leg.call(world, save, random, data, path) @@ -10668,3 +10698,77 @@ func _gen1_fuchsia_gym_leg( if not world.state.is_engine_flag_active(Gen2WorldState.gen1_badge_flag(GEN1_BIT_SOULBADGE)): return {"ok": false, "path": path, "reason": "fuchsia_gym_koga: SOULBADGE is clear"} return _gen1_warp_legs(path, world, save, random, data, [[GEN1_FUCHSIA_CITY, "fuchsia_gym_exit"]]) + + +func _gen1_safari_zone_leg( + world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, + path: Array +) -> Dictionary: + var stepped: Dictionary = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_SAFARI_ZONE_GATE, "safari_gate_entry", GEN1_SAFARI_GATE_DOOR], + ]) + if not bool(stepped["ok"]): + return stepped + var offer: Dictionary = _gen1_walk(world, GEN1_SAFARI_GATE_OFFER, save, random, data) + stepped = _gen1_step(path, "safari_gate_fee", world, offer, { + "balls": world.state.safari_balls(), "steps": world.state.safari_steps()}) + if not bool(stepped["ok"]): + return stepped + ## `SafariZoneEntranceAutoWalk` has already walked the player into the centre. + if not world.gen1_safari_active() or world.map_id() != Vector2i(0, GEN1_SAFARI_ZONE_CENTER): + return {"ok": false, "path": path, "reason": "safari_gate_fee: the fee left the player on %s" % [ + _map_value(world)]} + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_SAFARI_ZONE_EAST, "safari_center_to_east", GEN1_SAFARI_CENTER_EAST], + [GEN1_SAFARI_ZONE_NORTH, "safari_east_to_north", GEN1_SAFARI_EAST_NORTH], + [GEN1_SAFARI_ZONE_WEST, "safari_north_to_west", GEN1_SAFARI_NORTH_WEST], + [GEN1_SAFARI_ZONE_SECRET_HOUSE, "safari_secret_house", GEN1_SAFARI_SECRET_HOUSE_DOOR], + ]) + if not bool(stepped["ok"]): + return stepped + var guru: Dictionary = _gen1_talk(world, GEN1_BELOW_FISHING_GURU, Gen2WorldSprite.FACING_UP, save, random, data) + stepped = _gen1_step(path, "safari_secret_house_hm03", world, guru, { + "items": _named_items(data, world.state.items()), "steps": world.state.safari_steps()}) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_flag_leg(path, "safari_secret_house_hm03", world, GEN1_EVENT_GOT_HM03, "EVENT_GOT_HM03") + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_SAFARI_ZONE_WEST, "safari_secret_house_exit"], + ]) + if not bool(stepped["ok"]): + return stepped + var teeth: Dictionary = _gen1_talk(world, GEN1_BELOW_GOLD_TEETH, Gen2WorldSprite.FACING_UP, save, random, data) + stepped = _gen1_step(path, "safari_gold_teeth", world, teeth, { + "items": _named_items(data, world.state.items()), "steps": world.state.safari_steps()}) + if not bool(stepped["ok"]): + return stepped + if int(world.state.items().get(GEN1_GOLD_TEETH, 0)) != 1 or int(world.state.items().get(GEN1_HM03, 0)) != 1: + return {"ok": false, "path": path, "reason": "safari_gold_teeth: the bag holds %s" % [ + _named_items(data, world.state.items())]} + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_SAFARI_ZONE_NORTH, "safari_west_to_north", GEN1_SAFARI_WEST_NORTH], + [GEN1_SAFARI_ZONE_EAST, "safari_north_to_east", GEN1_SAFARI_NORTH_EAST], + [GEN1_SAFARI_ZONE_CENTER, "safari_east_to_center", GEN1_SAFARI_EAST_CENTER], + [GEN1_SAFARI_ZONE_GATE, "safari_center_to_gate", GEN1_SAFARI_CENTER_GATE], + [GEN1_FUCHSIA_CITY, "safari_gate_exit", GEN1_SAFARI_GATE_SOUTH], + [GEN1_WARDENS_HOUSE, "wardens_house_entry", GEN1_WARDENS_HOUSE_DOOR], + ]) + if not bool(stepped["ok"]): + return stepped + var warden: Dictionary = _gen1_talk_to(world, GEN1_WARDEN, save, random, data) + stepped = _gen1_step(path, "wardens_house_hm04", world, warden, { + "items": _named_items(data, world.state.items())}) + if not bool(stepped["ok"]): + return stepped + for flag: Array in [ + [GEN1_EVENT_GAVE_GOLD_TEETH, "EVENT_GAVE_GOLD_TEETH"], [GEN1_EVENT_GOT_HM04, "EVENT_GOT_HM04"], + ]: + stepped = _gen1_flag_leg(path, "wardens_house_hm04", world, int(flag[0]), String(flag[1])) + if not bool(stepped["ok"]): + return stepped + if int(world.state.items().get(GEN1_HM04, 0)) != 1: + return {"ok": false, "path": path, "reason": "wardens_house_hm04: the bag holds %s" % [ + _named_items(data, world.state.items())]} + return _gen1_warp_legs(path, world, save, random, data, [[GEN1_FUCHSIA_CITY, "wardens_house_exit"]]) From a9c7964b723f03310d51a97af5e8285a4980483c Mon Sep 17 00:00:00 2001 From: Decrypt <90518536+Decryptu@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:39:21 +0200 Subject: [PATCH 6/8] Walk Red, Blue and Yellow from the Safari Zone to Sabrina and keep a card key door open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The story walk buys FRESH WATER on Celadon Mart's roof, hands it to Route 7's guard, climbs Silph Co. to the CARD KEY, opens 3F's and 11F's doors, fights the rival and Giovanni, takes the MASTER BALL, rides the pads back to 3F's car and crosses Saffron Gym's pads to the MARSHBADGE on all three cartridges: six badges and four HMs. PrintCardKeyText sets BIT_CUR_MAP_LOADED_1 behind the block it replaces, so the floor's callback turns wCardKeyDoorY into the door's own flag on the next frame. The port flagged it on the next map load instead, and opening a second door elsewhere first overwrote the coordinates: 3F's door shut again on the way back from 11F. The card key steps set the bit and the map-loaded pass runs the unlock. gen1_walk pins the flag on the frame after the box. A text_bcd over a buffer the script has written every byte of is filled at import: Yellow's Safari gate said where Red spells ¥500, and HiddenCoins' two boxes said on all three. gen1_maps refuses a number marker in any map's script and pins the fee line. Cache format 130. Route 7's gate is entered on its lower cell alone, as block $5F's arch tiles are not in Overworld_Coll. A pad that warps on its own map counts as arriving, which is how Saffron Gym is crossed. --- README.md | 2 +- game/battle/accuracy.gd | 5 - game/battle/battle_anim_script.gd | 5 - game/battle/battle_renderer.gd | 5 - game/gen1/gen1_world_importer.gd | 32 +++- game/import/rom_cache.gd | 2 +- game/mods/mod_host.gd | 19 -- game/world/world_api.gd | 12 +- tests/unit/test_source_budget.gd | 2 +- tools/checks/gen1_maps.gd | 27 +++ tools/checks/gen1_walk.gd | 13 +- tools/preview_world_story.gd | 306 +++++++++++++++++++++++++++++- 12 files changed, 376 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 7a59b675..6822902d 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ The rest are previews and dumps, each driving a real screen or table: ```bash # the full walked route: Johto, the Hall of Fame, every Kanto gym, and Red godot --headless --path . -s res://tools/preview_world_story.gd -- crystal 24 7 2 2 1 none home story -# Red, Blue or Yellow from the bedroom to the Safari Zone and HM04 +# Red, Blue or Yellow from the bedroom to the MARSHBADGE godot --headless --path . -s res://tools/preview_world_story.gd -- red 0 38 3 6 1 none home story ``` diff --git a/game/battle/accuracy.gd b/game/battle/accuracy.gd index a8aaad9d..d6115bdc 100644 --- a/game/battle/accuracy.gd +++ b/game/battle/accuracy.gd @@ -2,11 +2,9 @@ class_name Gen2Accuracy extends RefCounted ## Whether a move connects. -## ## Accuracy is a byte out of 255, not a percentage: "100%" stores 255 and "90%" ## stores 229. Kept as the byte, because the roll is against it and a stored 255 ## is a special case that never misses. -## ## Accuracy and evasion use their own multiplier table, a different shape from ## the other stats', so -1 accuracy and -1 Attack are different fractions. @@ -23,10 +21,8 @@ const STAGE_MULTIPLIERS: Array = [ ## The chance a move connects, out of 255. -## ## Evasion reads the same table from the other end rather than a second table: ## +2 evasion is the multiplier accuracy uses at -2. -## ## [param foresight] drops both sides' stages, not only evasion, and only when ## the evasion stage is at least the accuracy stage, so it cannot undo an ## accuracy the attacker raised. @@ -58,7 +54,6 @@ static func apply_stage(value: int, stage: int) -> int: ## Rolls a hit against a chance out of 255. -## ## A chance of exactly 255 connects without rolling. Rolling would miss one time ## in 256, and Crystal goes out of its way not to. [param generation] is ## `MoveHitTest`, which does not: pret's own comment there says even the highest diff --git a/game/battle/battle_anim_script.gd b/game/battle/battle_anim_script.gd index a0e60cbd..0c0a896c 100644 --- a/game/battle/battle_anim_script.gd +++ b/game/battle/battle_anim_script.gd @@ -4,7 +4,6 @@ extends RefCounted ## The battle animation command interpreter (engine/battle_anims/anim_commands.asm). ## Nothing here draws, plays a sound or touches a palette: the commands are ## reported and whoever is drawing decides what they look like. -## ## Anything below [constant FIRST_COMMAND] is a delay rather than a command, so ## `anim_wait N` is exactly N frames. The control flow is the cartridge's single ## `wBattleAnimParent` word and single `wBattleAnimLoops` byte, so a call inside a @@ -116,7 +115,6 @@ const WOBBLE_CAUGHT: int = 1 const WOBBLE_ESCAPED: int = 2 ## How many commands one frame may run before the script is abandoned. -## ## The cartridge has no such limit: `.RunScript` loops until a delay byte or a ## top-level `anim_ret`, and a script that does neither hangs the hardware. This ## is ours, so a malformed cached region or a mod's own script costs a refused @@ -151,7 +149,6 @@ var _failed: bool = false ## An interpreter positioned at [param start_address] inside [param region], which ## starts at [param base_address]. -## ## [param param] is `wBattleAnimParam`, which the caller sets before playing; ## `wBattleAnimVar` starts at zero because `ClearBattleAnims` clears it. static func create( @@ -173,7 +170,6 @@ static func create( ## [code]{ ok, name, byte, operands, size, target }[/code]; `target` is the ## start_address a branch would take, or -1. A delay byte answers [constant WAIT] with ## its own value as its single operand. -## ## Shared with [Gen2BattleAnimImporter], which walks every body with it, so the ## vocabulary is stated once. static func decode_command( @@ -250,7 +246,6 @@ func variable() -> int: ## One hardware frame of `RunBattleAnimCommand`, as an Array of the commands it ## ran, each [code]{ name, byte, operands }[/code]. -## ## Empty while a delay is counting down, which is most frames, and empty once ## the script has stopped. A frame that runs commands ends either on the delay ## byte that set the next pause or on the `anim_ret` that ended the script, and diff --git a/game/battle/battle_renderer.gd b/game/battle/battle_renderer.gd index b14eacb0..9f522cea 100644 --- a/game/battle/battle_renderer.gd +++ b/game/battle/battle_renderer.gd @@ -4,7 +4,6 @@ extends Control ## Draws the battle field: two pics, two status panels, two HP bars, the exp bar ## and whatever an animation is putting over them. Call [method set_battle_data] ## once, then [method set_view] whenever the screen has new display values. -## ## The pics are drawn through `wTilemap` rather than placed at a corner, because ## that map is what an animation edits. Panels compose into one screen-sized index ## buffer with index 0 transparent. Every background layer is one plane, so the @@ -436,7 +435,6 @@ static func minimize_pixels( ## `GetSubstitutePic`: a blank box with four tiles of [param strip], the monster ## overworld sprite, copied into it. The doll wears whichever battler palette its ## box sits in, since nothing writes one for it. -## ## Static because it is the whole of the picture and takes no screen: a check ## sweeping three caches builds it the same way the renderer does. static func substitute_pixels( @@ -582,7 +580,6 @@ static func _append_animation( ## A battler pic's own palette, permuted by whatever DMG byte the animation's ## last `BattleAnimRequestPals` left on that palette slot. -## ## `CGB_BattleColors` reads `CheckShininess` on both sides, so the shiny palette ## is the picture's for the whole fight and not just the gold sweep the entrance ## plays over it. @@ -776,7 +773,6 @@ func _draw_hud_balls() -> void: ## `wShadowOAM` as the animation left it: up to forty sprites, each eight by ## eight, in the order they were written, so a later one draws over an earlier. -## ## Objects are not part of the background plane and take no scroll. Index 0 is ## transparent, which is what OAM's own colour 0 is. func _draw_sprites() -> void: @@ -850,7 +846,6 @@ func _blit_sprite(into: Image, sprite: Dictionary, backpic: bool = false) -> voi ## The eight pixels by eight of one animation tile, found through the window ## [method Gen2BattleAnimPlayer.tiles] describes, counted from ## `BATTLEANIM_BASE_TILE`. -## ## A window tile is either an imported sheet's or one of the battle's own two ## pictures, which is what `anim_battlergfx_1row` and `..._2row` put there so an ## effect can move a battler as objects. diff --git a/game/gen1/gen1_world_importer.gd b/game/gen1/gen1_world_importer.gd index 39400f71..ecdcfa1e 100644 --- a/game/gen1/gen1_world_importer.gd +++ b/game/gen1/gen1_world_importer.gd @@ -1580,13 +1580,20 @@ static func _hidden_coin_nodes( if index < 0: return [] var flag: int = Gen1Layout.engine_flag_base("obtained_hidden_coins") + index - var found: String = predef_text(rom, layout, bank, "found_hidden_coins") - var dropped: String = predef_text(rom, layout, bank, "dropped_hidden_coins") + var amount: int = Gen1Layout.hidden_coin_amount(argument) + ## Both boxes print `hCoins`, which holds the amount just added. + var coins: int = int(layout["money_hram"]) + Gen1Layout.COIN_BUFFER_AT + var found: String = _number_filled( + predef_text(rom, layout, bank, "found_hidden_coins"), coins, amount + ) + var dropped: String = _number_filled( + predef_text(rom, layout, bank, "dropped_hidden_coins"), coins, amount + ) if found.is_empty() or dropped.is_empty(): return [] return [{"op": "has_item", "item": Gen1Layout.ITEM_COIN_CASE, "else": [], "then": [ {"op": "branch", "flag": flag, "engine": true, "then": [], "else": [ - {"op": "add_coins", "amount": Gen1Layout.hidden_coin_amount(argument)}, + {"op": "add_coins", "amount": amount}, {"op": "flag", "flag": flag, "set": true, "engine": true}, {"op": "has_coins", "coins": Gen1Layout.HIDDEN_COIN_CEILING, "test": "exactly", @@ -1918,11 +1925,26 @@ static func _script_ended(state: Dictionary, out: Array) -> Variant: return out -static func _script_printed(state: Dictionary, out: Array, box: Dictionary) -> void: +## A `text_bcd` over a buffer the walk wrote every byte of prints that number, +## which is how Yellow's Safari gate says ¥500 where Red's text spells it. +static func _script_printed(ctx: Dictionary, state: Dictionary, out: Array, box: Dictionary) -> void: + var layout: Dictionary = ctx["layout"] + for name: String in Gen1Layout.SCRIPT_BCD_BUFFERS: + var value: int = _script_bcd_value(state, name) + if value >= 0: + box["text"] = _number_filled(String(box["text"]), int(layout[name]), value) out.append(box) state["last_box"] = box +## `PrintBCDNumber` with LEADING_ZEROES | LEFT_ALIGN, which every map text's +## `text_bcd` passes: the digits alone. +static func _number_filled(text: String, address: int, value: int) -> String: + return Gen2TextStream.fill_all_markers( + text, "%s%04X>" % [Gen2TextStream.NUMBER_MARKER, address], str(value) + ) + + ## One instruction: the next address, [constant SCRIPT_END] or SCRIPT_UNREAD. ## The register writes are here and [method _script_flow] has the rest. static func _script_step( @@ -5308,7 +5330,7 @@ static func _script_text_row( out.append({"op": "facility", "command": int(decoded["command"])}) return true if not String(decoded["text"]).is_empty(): - _script_printed(state, out, {"op": "text", "text": String(decoded["text"])}) + _script_printed(ctx, state, out, {"op": "text", "text": String(decoded["text"])}) var code: int = _text_code_at(decoded) if code < 0: return true diff --git a/game/import/rom_cache.gd b/game/import/rom_cache.gd index 76757b02..d94c726d 100644 --- a/game/import/rom_cache.gd +++ b/game/import/rom_cache.gd @@ -77,7 +77,7 @@ const BYTES_KEY: String = "bytes" ## a dump the owner still has, so re-importing costs a few seconds and a ## migration would have to carry every past shape forever. Nothing but the cache ## is thrown away, since saves live under their own root. -const FORMAT_VERSION: int = 129 +const FORMAT_VERSION: int = 130 ## What [method state] answers. A stale cache is told from a missing one because ## they need different things said to whoever is looking at it: one is a diff --git a/game/mods/mod_host.gd b/game/mods/mod_host.gd index b1d68e60..40fa502a 100644 --- a/game/mods/mod_host.gd +++ b/game/mods/mod_host.gd @@ -5,7 +5,6 @@ extends RefCounted ## never touches scene nodes or engine internals: it is handed this host, ## registers what it provides, and is done, reaching only cartridge content ## through [GameData] and live world state through [Gen2WorldAPI]. -## ## Both renderers are replaceable this way, neither requiring 2D, which is why ## the contract is a factory and [method select_view] swaps between them. Mods are ## interpreted GDScript, iOS forbidding JIT and runtime native loading. @@ -26,7 +25,6 @@ const BATTLE_RENDERER_METHODS: Array[String] = [ ## rectangle at window resolution instead of the 160x144 viewport. A view built ## from geometry cannot be drawn into a 160x144 buffer and magnified, so this is ## what makes a 3D or HD renderer possible at all. -## ## A renderer that does not define it draws in hardware pixels, which is what the ## built-in ones do and what a tile-recolouring mod wants. Shared by both ## renderer kinds. @@ -71,7 +69,6 @@ const RENDERER_INPUT_METHOD: String = "handle_world_input" ## Optional, battle renderers only. The same seam on the battle side: every event ## [Gen2BattleScreen] did not claim, so a renderer composing its own shot can let ## someone steer it. Answering true consumes the event. -## ## A [PokeButton] never arrives here, on either side. The screen routes every one ## of them to whatever owns it first, so a text box, the forget-move list and ## ball selection all take their press before a renderer could see it, and what @@ -98,7 +95,6 @@ const RENDERER_INTERFACE_MASK_METHOD: String = "set_interface_masked" ## Optional, both renderer kinds, and only meaningful on the native layer. Called ## beside [constant RENDERER_RESIZE_METHOD] with the cartridge's own 160x144 ## screen expressed in that layer's pixels. -## ## Every hardware-pixel number a renderer is handed -- the text box's rectangle, ## first -- has to land somewhere inside the surface it draws on. Framed, that ## mapping was the surface itself, because it was a whole multiple of 160x144. @@ -167,7 +163,6 @@ const FIRST_MOD_POCKET: int = 5 ## steps along, a whole number in a range, and a button that does something the ## moment it is pressed. A button stores nothing, because "recentre the camera ## now" has no value to keep. -## ## A number is not a ladder with every rung written out: a randomizer's seed has ## ten thousand, and four one-digit ladders spend four menu rows on one field. const OPTION_LADDER: StringName = &"ladder" @@ -319,7 +314,6 @@ static func reset() -> void: ## Registers a world renderer under [param id]. -## ## [param script] is instantiated per world, so one registration serves a map ## change, a snapshot restore and a live switch between renderers. func register_world_renderer( @@ -463,7 +457,6 @@ func request_battle_message(id: StringName, text: String) -> Dictionary: ## The sounds a notice may ask for, by name, so a mod never names a raw effect ## number and never reaches one the host has not chosen to lend. -## ## `SFX_SHINE` ($5E) is deliberately absent: the sparkle means a shiny Pokemon ## and nothing else, and a mod firing it for something ordinary teaches a player ## to distrust it. `item` is `SFX_ITEM`, the jingle `FindItemInBallScript` plays, @@ -570,7 +563,6 @@ func set_hidden_items_source(source: Callable) -> void: ## The live world's own `{item: quantity}`, the copy a ## [method register_repel_renewal] provider is handed, and empty when no world is ## open. Read only, and a copy: writing the bag is the host's. -## ## One narrow accessor rather than a handle on [Gen2WorldAPI], because a ## non-renderer mod is deliberately given no world at all. func inventory() -> Dictionary: @@ -590,7 +582,6 @@ func set_progress_source(source: Callable) -> void: ## What the run being played has achieved: badges, the Hall of Fame, the dex ## counts, what is kept, money, coins, the play timer. `{}` with no world open. -## ## A copy rather than a handle, and read only: every field is state the host ## owns. See [Gen2ModProgress] for the fields and for why an absent one is absent ## rather than zero. @@ -611,7 +602,6 @@ func progress_for(save: Gen2SaveData, data: GameData = null) -> Dictionary: ## Re-reads the live run and emits [signal progress_changed] where a field moved. ## Called by [Gen2WorldScreen] once a world pass. -## ## Nothing is read while nothing is connected: walking the party and every box is ## the expensive half of a reading, and a build with no mod watching must not pay ## for it. @@ -1223,7 +1213,6 @@ func page(id: StringName) -> Dictionary: ## What [param id]'s page lists now, asked fresh: a page is a view of state the ## mod holds and is answered when it is drawn rather than when it is registered. -## ## Every row is normalised here, so a screen never sees a shape a mod invented: ## a row with no `label` is dropped, `icon` is a Dictionary or absent, and ## `locked` is a bool. An answer that is not an Array is no rows at all. @@ -1259,7 +1248,6 @@ func menu_entries(menu: StringName) -> Array: ## The start-menu entries [code]context[/code] leaves visible, in registration order. -## ## An entry that registered no `visible` predicate is always listed. One that did ## is asked with a copy of the context, so deciding whether to appear cannot ## change the menu being built; answering false leaves the row ABSENT rather than @@ -1726,7 +1714,6 @@ static func _option_name(id: StringName, key: StringName) -> String: ## Adds a species, move, item or trainer class the cartridge does not have. -## ## [param number] has to be at or above ## [constant Gen2ContentOverlay.FIRST_MOD_NUMBER], and [param row] is a partial ## row: whatever it leaves out comes from the kind's defaults. Everything a @@ -1780,7 +1767,6 @@ func patch_type_matchup( ## slots [method GameData.world_encounter] answers with, which is what a ## randomizer rewrites. [param method] is one of ## [constant Gen2ContentOverlay.ENCOUNTER_METHODS]. -## ## `slots` and `rates` are arrays and replace whole. Patching a map this ## cartridge does not carry changes nothing, exactly as a species patch does. func patch_encounter( @@ -1874,7 +1860,6 @@ func text_overlay() -> Gen2TextOverlay: ## Watches one of [constant CHANNELS]. [param handler] is called with each event ## dictionary as it reaches the screen showing it. -## ## Reading only. A subscriber is handed a copy of the event after the channel's ## optional presentation mutator has run. func subscribe(channel: StringName, id: StringName, handler: Callable) -> Dictionary: @@ -1928,7 +1913,6 @@ func unregister_event_mutator(channel: StringName, id: StringName) -> void: ## Hands [param event] through the optional presentation rewrite and then to ## every watcher. Returns the effective event for the screen to consume. -## ## Static and null-safe on the instance, because this sits on the path every ## battle event and every world result takes: a game with no mods must not build ## a host, or copy an event, to publish to nobody. @@ -2230,7 +2214,6 @@ func save_lifecycle_ids() -> Array: ## A save that has just been made, before it is written or played. A provider ## snapshots whatever its run is built from into its own namespace here; there is ## nothing to clear, since the save carries no run yet. -## ## The installation's mod settings are copied onto the save first, so the run ## records what it was created with and a later change to the installation cannot ## reach back into it. See [method PokeModOptions.bind_run]. @@ -2282,7 +2265,6 @@ func _owns_manifest(manifest: PokeModManifest) -> bool: ## Runs each discovered mod's entry script, which registers what it provides. -## ## A mod that will not load is reported and skipped: one broken mod must not ## stop the others, and it must not stop the game starting. A mod the player ## switched off is skipped silently and is not a failure, and a mod for another @@ -2440,7 +2422,6 @@ func loaded_mods() -> Array: ## Mounts a mod's own resource pack, once per run. -## ## `replace_files` is false, so a pack only adds paths and never lands on one the ## game ships. The engine has no unmount, which is why a reload remounts nothing ## and the set is kept on the host rather than the manifest. diff --git a/game/world/world_api.gd b/game/world/world_api.gd index ebc59319..02586106 100644 --- a/game/world/world_api.gd +++ b/game/world/world_api.gd @@ -4103,7 +4103,10 @@ func _gen1_card_key_steps() -> Array: return [_gen1_card_key_box("card_key_fail")] door["type"] = &"block" door["card_key"] = true - return [_gen1_card_key_box("card_key_success"), door] + ## `set BIT_CUR_MAP_LOADED_1` behind the block: the floor's callback flags + ## the door on the next frame, so leaving and returning keeps it open. + return [_gen1_card_key_box("card_key_success"), door, + {"type": &"map_load", "bit": Gen1Layout.MAP_LOADED_1_BIT}] func _gen1_card_key_box(name: String) -> Dictionary: @@ -7583,7 +7586,6 @@ func _queue_map_callbacks(callback_type: int) -> void: if current_map == null: return if _gen1: - _unlock_gen1_card_key_door() ## `RunMapScript`'s first frame behind `EnterMap`: its writes stand before a ## step, its boxes wait for one, and `CheckAndResetEvent` answers only once. _gen1_entry_steps = _spend_gen1_nodes(_gen1_map_script_nodes(Gen1Layout.MAP_LOAD_BOTH)) @@ -7604,6 +7606,8 @@ func _queue_map_callbacks(callback_type: int) -> void: ## The map's script with [param mask] standing in `wCurrentMapScriptFlags`. func _gen1_map_script_nodes(mask: int) -> Array: + if mask & (1 << Gen1Layout.MAP_LOADED_1_BIT): + _unlock_gen1_card_key_door() for callback: Dictionary in current_map.scripts.get("callbacks", []) as Array: if int(callback.get("mask", 0)) == mask: return callback["nodes"] as Array @@ -7627,8 +7631,8 @@ func _spend_gen1_nodes(nodes: Array) -> Array: ## `_SetCardKeyDoorYScript` and `_UnlockedDoorEventScript`, which run -## in front of the blocks: the door `PrintCardKeyText` last opened becomes that -## door's own flag, and the coordinates are cleared on the floor that owns them. +## in front of the blocks on every BIT_CUR_MAP_LOADED_1 pass: the door +## `PrintCardKeyText` last opened becomes that door's own flag. func _unlock_gen1_card_key_door() -> void: if state == null: return diff --git a/tests/unit/test_source_budget.gd b/tests/unit/test_source_budget.gd index bea9805f..89cabc2a 100644 --- a/tests/unit/test_source_budget.gd +++ b/tests/unit/test_source_budget.gd @@ -21,7 +21,7 @@ const MAX_COMMENT_BLOCK: int = 8 ## the shared code, the largest of them 152 the region map, 150 the battle ## animation engine, 140 the transition, 131 the field moves, 130 the Pokedex, ## 108 the three PCs, 90 the Safari Zone, 52 the menus a row draws. -const MAX_COMMENT_LINES: int = 41947 +const MAX_COMMENT_LINES: int = 41939 ## What no comment block ever ends on. A pass that meets the ceiling above trims ## the second line of a two-line block and leaves the first mid-sentence, which diff --git a/tools/checks/gen1_maps.gd b/tools/checks/gen1_maps.gd index d64573a9..3ae46e59 100644 --- a/tools/checks/gen1_maps.gd +++ b/tools/checks/gen1_maps.gd @@ -715,6 +715,9 @@ func _walk_script( if op == "text" and not _drawn(font, String(node["text"]).replace("\n", "")) \ and wrong.size() < 4: wrong.append("map %d draws a blank tile" % number) + if op == "text" and String(node["text"]).contains(Gen2TextStream.NUMBER_MARKER) \ + and wrong.size() < 4: + wrong.append("map %d prints a number nothing wrote" % number) if node.has("flag") and op in ["flag", "branch", "flag_test"]: var flag: int = int(node["flag"]) if (flag < 0 or flag >= Gen1Layout.EVENT_FLAG_BYTES * 8) and wrong.size() < 4: @@ -803,6 +806,22 @@ func _hidden_events() -> void: _r.note("gen1 hidden events %s, %d bookshelf tiles" % [census, bookshelves]) +## Every box under [param rows]' scripts, branches included. +func _script_texts(rows: Array) -> Array[String]: + var out: Array[String] = [] + var pending: Array = [] + for row: Dictionary in rows: + pending.append(row.get("script", [])) + while not pending.is_empty(): + for node: Dictionary in pending.pop_back() as Array: + if String(node["op"]) == "text": + out.append(String(node["text"])) + for side: String in Gen1Layout.SCRIPT_BRANCH_KEYS: + if node.has(side): + pending.append(node[side]) + return out + + func _walk_hidden( nodes: Array, census: Dictionary, wrong: Array[String], number: int ) -> void: @@ -814,6 +833,9 @@ func _walk_hidden( wrong.append("map %d faces %d" % [number, int(node["facing"])]) if op == "text" and String(node["text"]).is_empty() and wrong.size() < 4: wrong.append("map %d prints nothing" % number) + if op == "text" and String(node["text"]).contains(Gen2TextStream.NUMBER_MARKER) \ + and wrong.size() < 4: + wrong.append("map %d prints a number nothing wrote" % number) if (op == "give_item" or op == "has_item" or op == "name_item") \ and _r.data.item_name(int(node["item"])).is_empty(): wrong.append("map %d names item %d" % [number, int(node["item"])]) @@ -1232,6 +1254,7 @@ const SAFARI_TEXTS: int = 6 const SAFARI_BATTLE_MAPS: int = 4 const SAFARI_WINDOW_MAPS: int = 9 const SAFARI_STEPS_LABEL: String = "/500" +const SAFARI_FEE_LINE: String = "That'll be ¥500" const SAFARI_MENU_TOP: String = "BALL×" @@ -1243,6 +1266,10 @@ func _safari() -> void: "the gate reads %d states." % (gate.scripts["states"] as Array).size()) _r.check(gate.texts.size() == SAFARI_TEXTS, "the gate reads %d text rows." % gate.texts.size()) + ## Yellow's `text_bcd wPriceTemp` where Red spells the fee out. + _r.check(_script_texts(gate.texts).any( + func(text: String) -> bool: return text.contains(SAFARI_FEE_LINE)), + "no gate text says %s." % [SAFARI_FEE_LINE]) var battle_maps: int = 0 var window_maps: int = 0 for number: int in _maps: diff --git a/tools/checks/gen1_walk.gd b/tools/checks/gen1_walk.gd index cb69372d..d5e87317 100644 --- a/tools/checks/gen1_walk.gd +++ b/tools/checks/gen1_walk.gd @@ -778,17 +778,22 @@ func _check_a_card_key_door() -> void: "the door opened at %s was remembered as %s." % [ SILPH_DOOR, world.state.card_key_door(), ]) + ## `set BIT_CUR_MAP_LOADED_1`: the next frame's callback turns the + ## coordinates into the door's flag before the player takes a step. + world.dispatch_sight_events() + var flag: int = int((world.current_map.events["card_key"] as Array)[0]["flag"]) + _r.check(world.state.card_key_door() == Gen2WorldState.NO_CARD_KEY_DOOR + and world.event_flag_active(flag), + "the frame after the box kept %s and flag %d %s." % [ + world.state.card_key_door(), flag, world.event_flag_active(flag)]) - ## The floor loaded again: the coordinates become the door's flag and the - ## callback leaves that one alone. + ## The floor loaded again: the callback leaves the flagged door alone. var again: Gen2WorldAPI = _r.open_world( 0, SILPH_CO_2F, SILPH_DOOR_APPROACH, world.state ) if again == null: return again.dispatch_map_entry() - _r.check(again.state.card_key_door() == Gen2WorldState.NO_CARD_KEY_DOOR, - "the reloaded floor kept %s." % [again.state.card_key_door()]) _r.check(again.block_at(SILPH_DOOR.x, SILPH_DOOR.y) == SILPH_OPEN_BLOCK, "the reloaded floor blocked the opened door with $%02X." % again.block_at( SILPH_DOOR.x, SILPH_DOOR.y diff --git a/tools/preview_world_story.gd b/tools/preview_world_story.gd index dbab8340..21e07cb4 100644 --- a/tools/preview_world_story.gd +++ b/tools/preview_world_story.gd @@ -29,8 +29,12 @@ const REQUEST_HANDLERS: Dictionary = { &"magnet_train_requested": &"_request_magnet_train", &"pokedex_entry_requested": &"_request_audio", &"elevator_requested": &"_request_elevator", + &"vending_requested": &"_request_vending", } +## The drink the next `VendingMachineMenu` buys, as an item number; 0 is B. +var _gen1_drink: int = 0 + ## The map number the next `DisplayElevatorFloorMenu` is answered with; -1 is B. var _gen1_elevator_floor: int = -1 @@ -8802,6 +8806,18 @@ func _request_elevator(world: Gen2WorldAPI, request: Dictionary, state: Dictiona return world.complete_runtime_request({"ok": true}) +func _request_vending(world: Gen2WorldAPI, request: Dictionary, state: Dictionary) -> Array: + for row: Dictionary in (request.get("values", {}) as Dictionary).get("rows", []): + if int(row.get("item", 0)) != _gen1_drink: + continue + _gen1_drink = 0 + var bought: Dictionary = Gen2WorldMartHost.vend(world, state["save"], row, false) + if not bool(bought.get("ok", false)): + return _request_failed(state, "the machine refused: %s" % bought.get("reason", ""), bought) + break + return world.complete_runtime_request({"ok": true}) + + ## `DisplayPokedex` behind a Generation 1 ball is a page with nothing to answer, ## the way `waitsfx` is. func _request_audio(world: Gen2WorldAPI, _request: Dictionary, _state: Dictionary) -> Array: @@ -9513,6 +9529,11 @@ const GEN1_ROUTE_12_GATE_DOOR := Vector2i(10, 15) const GEN1_ROUTE_12_GATE_SOUTH := Vector2i(4, 7) const GEN1_ROUTE_15_GATE_DOOR := Vector2i(14, 8) const GEN1_ROUTE_15_GATE_WEST := Vector2i(0, 4) +const GEN1_ROUTE_15_WEST_DOOR := Vector2i(7, 8) +const GEN1_ROUTE_15_GATE_EAST_EXIT := Vector2i(7, 4) +const GEN1_ROUTE_14_EAST_EDGE := Vector2i(19, 8) +const GEN1_ROUTE_12_GATE_SOUTH_DOOR := Vector2i(10, 21) +const GEN1_ROUTE_12_GATE_NORTH := Vector2i(4, 0) const GEN1_ABOVE_SNORLAX := Vector2i(10, 61) const GEN1_FUCHSIA_GYM_DOOR := Vector2i(5, 27) const GEN1_KOGA := Vector2i(4, 10) @@ -9550,6 +9571,70 @@ const GEN1_GOLD_TEETH: int = 0x40 const GEN1_EVENT_GOT_HM03: int = 2176 const GEN1_EVENT_GOT_HM04: int = 568 const GEN1_EVENT_GAVE_GOLD_TEETH: int = 569 +const GEN1_CELADON_MART_1F: int = 122 +const GEN1_CELADON_MART_ROOF: int = 126 +const GEN1_CELADON_MART_ELEVATOR: int = 127 +const GEN1_CELADON_MART_5F: int = 136 +const GEN1_ROUTE_7_GATE: int = 76 +const GEN1_SAFFRON_CITY: int = 10 +const GEN1_SAFFRON_GYM: int = 178 +const GEN1_SILPH_CO_1F: int = 181 +const GEN1_SILPH_CO_2F: int = 207 +const GEN1_SILPH_CO_3F: int = 208 +const GEN1_SILPH_CO_4F: int = 209 +const GEN1_SILPH_CO_5F: int = 210 +const GEN1_SILPH_CO_7F: int = 212 +const GEN1_SILPH_CO_9F: int = 233 +const GEN1_SILPH_CO_11F: int = 235 +const GEN1_CELADON_MART_DOOR := Vector2i(8, 13) +const GEN1_MART_ELEVATOR_DOOR := Vector2i(1, 1) +const GEN1_MART_CAR_PANEL := Vector2i(3, 1) +const GEN1_MART_CAR_DOOR := Vector2i(1, 3) +const GEN1_MART_5F_ROOF_STAIRS := Vector2i(12, 1) +const GEN1_BELOW_VENDING_MACHINE := Vector2i(10, 2) +const GEN1_MART_ROOF_STAIRS := Vector2i(15, 2) +const GEN1_MART_1F_DOOR := Vector2i(2, 7) +const GEN1_ROUTE_7_GATE_WEST := Vector2i(11, 10) +const GEN1_ROUTE_7_GATE_EAST := Vector2i(5, 3) +const GEN1_SILPH_CO_DOOR := Vector2i(18, 21) +## Silph Co.'s stairs alternate between the two top-row cells floor by floor. +const GEN1_SILPH_STAIRS_RIGHT := Vector2i(26, 0) +const GEN1_SILPH_STAIRS_LEFT := Vector2i(24, 0) +## 5F's CARD KEY lies on the bottom row a Rocket seals from the corridor, so the +## teleporter beside him is ridden to 9F and back to land past him. +const GEN1_SILPH_5F_PAD_TO_9F := Vector2i(9, 15) +const GEN1_SILPH_9F_PAD := Vector2i(17, 15) +const GEN1_BESIDE_CARD_KEY := Vector2i(20, 16) +const GEN1_SILPH_5F_PAD_TO_3F := Vector2i(11, 5) +const GEN1_SILPH_3F_DOOR_SIDE := Vector2i(18, 8) +const GEN1_SILPH_3F_PAD_TO_7F := Vector2i(11, 11) +const GEN1_SILPH_7F_PAD_TO_11F := Vector2i(5, 7) +const GEN1_SILPH_11F_DOOR_SIDE := Vector2i(6, 14) +const GEN1_SILPH_GIOVANNI := Vector2i(6, 9) +const GEN1_SILPH_PRESIDENT := Vector2i(7, 5) +## 11F's car stands behind walls the pad's side never reaches, so the pads are +## ridden back to 3F and its car taken. +const GEN1_SILPH_11F_PAD_TO_7F := Vector2i(3, 2) +const GEN1_SILPH_7F_PAD_TO_3F := Vector2i(5, 3) +const GEN1_SILPH_3F_ELEVATOR := Vector2i(20, 0) +const GEN1_SILPH_CAR_PANEL := Vector2i(3, 1) +const GEN1_SILPH_CAR_DOOR := Vector2i(1, 3) +const GEN1_SILPH_CO_ELEVATOR: int = 236 +const GEN1_SAFFRON_GYM_DOOR := Vector2i(34, 3) +## The pads from the gym's door to Sabrina's room, in riding order. +const GEN1_SAFFRON_GYM_PADS: Array = [ + Vector2i(11, 15), Vector2i(15, 15), Vector2i(15, 5), Vector2i(1, 5), +] +const GEN1_SABRINA := Vector2i(9, 8) +const GEN1_FRESH_WATER: int = 0x3C +const GEN1_CARD_KEY: int = 0x30 +const GEN1_MASTER_BALL: int = 0x01 +const GEN1_EVENT_BEAT_SILPH_CO_RIVAL: int = 1856 +const GEN1_EVENT_GOT_MASTER_BALL: int = 1933 +const GEN1_EVENT_BEAT_SILPH_CO_GIOVANNI: int = 1935 +const GEN1_EVENT_GOT_TM46: int = 864 +const GEN1_EVENT_BEAT_SABRINA: int = 865 +const GEN1_BIT_MARSHBADGE: int = 5 const GEN1_BEDROOM_STAIRS := Vector2i(7, 1) const GEN1_HOUSE_DOOR := Vector2i(2, 7) ## `PalletTownDefaultScript` stops the player at `wYCoord == 1`, and Yellow's at 0. @@ -9631,7 +9716,8 @@ func _gen1_story_path(data: GameData) -> Dictionary: _gen1_cerulean_gym_leg, _gen1_cerulean_thief_leg, _gen1_ss_anne_leg, _gen1_vermilion_gym_leg, _gen1_rock_tunnel_leg, _gen1_celadon_gym_leg, _gen1_rocket_hideout_leg, _gen1_pokemon_tower_leg, _gen1_fuchsia_gym_leg, - _gen1_safari_zone_leg, + _gen1_safari_zone_leg, _gen1_saffron_drink_leg, _gen1_silph_co_leg, + _gen1_saffron_gym_leg, ] for leg: Callable in legs: var walked: Dictionary = leg.call(world, save, random, data, path) @@ -9730,8 +9816,10 @@ func _gen1_walk( return {"ok": false, "reason": settled.get("reason", ""), "details": settled.get("details", ""), "runs": runs} if not bool(walked.get("ok", false)): return {"ok": false, "reason": walked.get("reason", ""), "runs": runs} - if world.player_cell == cell or world.map_id() != from: - return {"ok": true, "runs": runs} + ## A pad on the same map lands elsewhere on it, as Saffron Gym's do. + var warped: bool = world.map_id() != from or int(settled.get("warps", 0)) > 0 + if world.player_cell == cell or warped: + return {"ok": true, "runs": runs, "warped": warped} return {"ok": false, "reason": "walk to %s kept being interrupted" % cell, "runs": runs} @@ -9740,10 +9828,19 @@ func _gen1_warp_walk( data: GameData ) -> Dictionary: var from: Vector2i = world.map_id() + ## A pad the player landed on is taken again by stepping off and back on, + ## which is how Silph Co.'s teleporters are ridden twice. + if world.player_cell == cell: + for direction: Vector2i in [Vector2i.DOWN, Vector2i.UP, Vector2i.LEFT, Vector2i.RIGHT]: + if world.can_walk_to(cell + direction) and not world.warp_pending(cell + direction): + var aside: Dictionary = _gen1_walk(world, cell + direction, save, random, data) + if not bool(aside.get("ok", false)): + return aside + break var walked: Dictionary = _gen1_walk(world, cell, save, random, data) if not bool(walked.get("ok", false)): return walked - if world.map_id() != from: + if bool(walked.get("warped", false)): walked["transition"] = {"ok": true, "from_map": from, "to_map": world.map_id(), "to_cell": world.player_cell} return walked ## `CheckWarpsCollision`: a mat the landing did not take is left by pressing @@ -10772,3 +10869,204 @@ func _gen1_safari_zone_leg( return {"ok": false, "path": path, "reason": "wardens_house_hm04: the bag holds %s" % [ _named_items(data, world.state.items())]} return _gen1_warp_legs(path, world, save, random, data, [[GEN1_FUCHSIA_CITY, "wardens_house_exit"]]) + + +## Back to Celadon's roof for a FRESH WATER and through Route 7's gate on it. +func _gen1_saffron_drink_leg( + world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, + path: Array +) -> Dictionary: + var stepped: Dictionary = _gen1_warp_legs(path, world, save, random, data, [ + ["east", GEN1_ROUTE_15, "fuchsia_to_route_15"], + [GEN1_ROUTE_15_GATE_1F, "route_15_gate_back", GEN1_ROUTE_15_WEST_DOOR], + [GEN1_ROUTE_15, "route_15_gate_east", GEN1_ROUTE_15_GATE_EAST_EXIT], + ["east", GEN1_ROUTE_14, "route_15_to_route_14"], + ["east", GEN1_ROUTE_13, "route_14_to_route_13", GEN1_ROUTE_14_EAST_EDGE], + ["north", GEN1_ROUTE_12, "route_13_to_route_12"], + [GEN1_ROUTE_12_GATE_1F, "route_12_gate_back", GEN1_ROUTE_12_GATE_SOUTH_DOOR], + [GEN1_ROUTE_12, "route_12_gate_north", GEN1_ROUTE_12_GATE_NORTH], + ["north", GEN1_LAVENDER_TOWN, "route_12_to_lavender"], + ["west", GEN1_ROUTE_8, "lavender_to_route_8_again"], + [GEN1_UNDERGROUND_PATH_ROUTE_8, "route_8_to_underground_again", GEN1_ROUTE_8_UNDERGROUND], + [GEN1_UNDERGROUND_PATH_WEST_EAST, "underground_east_entrance_again"], + [GEN1_UNDERGROUND_PATH_ROUTE_7, "underground_west_exit_again"], + [GEN1_ROUTE_7, "underground_to_route_7_again"], + ["west", GEN1_CELADON_CITY, "route_7_to_celadon_again"], + [GEN1_CELADON_MART_1F, "celadon_mart_entry", GEN1_CELADON_MART_DOOR], + [GEN1_CELADON_MART_ELEVATOR, "celadon_mart_elevator", GEN1_MART_ELEVATOR_DOOR], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_ride(path, world, save, random, data, "celadon_mart_to_5f", + GEN1_MART_CAR_PANEL, GEN1_MART_CAR_DOOR, GEN1_CELADON_MART_5F) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_CELADON_MART_ROOF, "celadon_mart_roof", GEN1_MART_5F_ROOF_STAIRS], + ]) + if not bool(stepped["ok"]): + return stepped + _gen1_drink = GEN1_FRESH_WATER + var machine: Dictionary = _gen1_talk( + world, GEN1_BELOW_VENDING_MACHINE, Gen2WorldSprite.FACING_UP, save, random, data + ) + stepped = _gen1_step(path, "celadon_mart_fresh_water", world, machine, { + "items": _named_items(data, world.state.items())}) + if not bool(stepped["ok"]): + return stepped + if int(world.state.items().get(GEN1_FRESH_WATER, 0)) != 1: + return {"ok": false, "path": path, "reason": "celadon_mart_fresh_water: the bag holds %s" % [ + _named_items(data, world.state.items())]} + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_CELADON_MART_5F, "celadon_mart_roof_down", GEN1_MART_ROOF_STAIRS], + [GEN1_CELADON_MART_ELEVATOR, "celadon_mart_elevator_down", GEN1_MART_ELEVATOR_DOOR], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_ride(path, world, save, random, data, "celadon_mart_to_1f", + GEN1_MART_CAR_PANEL, GEN1_MART_CAR_DOOR, GEN1_CELADON_MART_1F) + if not bool(stepped["ok"]): + return stepped + return _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_CELADON_CITY, "celadon_mart_exit", GEN1_MART_1F_DOOR], + ["east", GEN1_ROUTE_7, "celadon_to_route_7_again"], + [GEN1_ROUTE_7_GATE, "route_7_gate", GEN1_ROUTE_7_GATE_WEST], + [GEN1_ROUTE_7, "route_7_gate_east", GEN1_ROUTE_7_GATE_EAST], + ["east", GEN1_SAFFRON_CITY, "route_7_to_saffron"], + ]) + + +## The car's panel answered with [param landing], then its door. +func _gen1_ride( + path: Array, world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, + data: GameData, step: String, panel: Vector2i, door: Vector2i, landing: int +) -> Dictionary: + _gen1_elevator_floor = landing + var pressed: Dictionary = _gen1_talk(world, panel, Gen2WorldSprite.FACING_UP, save, random, data) + var stepped: Dictionary = _gen1_step(path, "%s_panel" % step, world, pressed) + if not bool(stepped["ok"]): + return stepped + if _gen1_elevator_floor >= 0: + return {"ok": false, "path": path, "reason": "%s: the car offered no floor on map %d" % [step, landing]} + return _gen1_warp_legs(path, world, save, random, data, [[landing, step, door]]) + + +## Silph Co.: the stairs to 5F, the CARD KEY past its Rocket, the pads to +## Giovanni on 11F, the president's MASTER BALL and the car down. +func _gen1_silph_co_leg( + world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, + path: Array +) -> Dictionary: + var stepped: Dictionary = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_SILPH_CO_1F, "silph_co_entry", GEN1_SILPH_CO_DOOR], + [GEN1_SILPH_CO_2F, "silph_1f_to_2f", GEN1_SILPH_STAIRS_RIGHT], + [GEN1_SILPH_CO_3F, "silph_2f_to_3f", GEN1_SILPH_STAIRS_RIGHT], + [GEN1_SILPH_CO_4F, "silph_3f_to_4f", GEN1_SILPH_STAIRS_LEFT], + [GEN1_SILPH_CO_5F, "silph_4f_to_5f", GEN1_SILPH_STAIRS_RIGHT], + [GEN1_SILPH_CO_9F, "silph_5f_pad_to_9f", GEN1_SILPH_5F_PAD_TO_9F], + [GEN1_SILPH_CO_5F, "silph_9f_pad_back", GEN1_SILPH_9F_PAD], + ]) + if not bool(stepped["ok"]): + return stepped + var key: Dictionary = _gen1_talk(world, GEN1_BESIDE_CARD_KEY, Gen2WorldSprite.FACING_RIGHT, save, random, data) + stepped = _gen1_step(path, "silph_5f_card_key", world, key, {"items": _named_items(data, world.state.items())}) + if not bool(stepped["ok"]): + return stepped + if int(world.state.items().get(GEN1_CARD_KEY, 0)) != 1: + return {"ok": false, "path": path, "reason": "silph_5f_card_key: the bag holds %s" % [ + _named_items(data, world.state.items())]} + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_SILPH_CO_9F, "silph_5f_pad_to_9f_again", GEN1_SILPH_5F_PAD_TO_9F], + [GEN1_SILPH_CO_5F, "silph_9f_pad_back_again", GEN1_SILPH_9F_PAD], + [GEN1_SILPH_CO_3F, "silph_5f_pad_to_3f", GEN1_SILPH_5F_PAD_TO_3F], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_card_key(path, "silph_3f_card_key", world, GEN1_SILPH_3F_DOOR_SIDE, + Gen2WorldSprite.FACING_LEFT, save, random, data) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_SILPH_CO_7F, "silph_3f_pad_to_7f", GEN1_SILPH_3F_PAD_TO_7F], + [GEN1_SILPH_CO_11F, "silph_7f_pad_to_11f", GEN1_SILPH_7F_PAD_TO_11F], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_flag_leg(path, "silph_7f_pad_to_11f", world, GEN1_EVENT_BEAT_SILPH_CO_RIVAL, "EVENT_BEAT_SILPH_CO_RIVAL") + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_card_key(path, "silph_11f_card_key", world, GEN1_SILPH_11F_DOOR_SIDE, + Gen2WorldSprite.FACING_UP, save, random, data) + if not bool(stepped["ok"]): + return stepped + var giovanni: Dictionary = _gen1_talk_to(world, GEN1_SILPH_GIOVANNI, save, random, data) + stepped = _gen1_step(path, "silph_11f_giovanni", world, giovanni, {"party": _party_species(save)}) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_flag_leg(path, "silph_11f_giovanni", world, GEN1_EVENT_BEAT_SILPH_CO_GIOVANNI, "EVENT_BEAT_SILPH_CO_GIOVANNI") + if not bool(stepped["ok"]): + return stepped + var president: Dictionary = _gen1_talk_to(world, GEN1_SILPH_PRESIDENT, save, random, data) + stepped = _gen1_step(path, "silph_11f_master_ball", world, president, { + "items": _named_items(data, world.state.items())}) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_flag_leg(path, "silph_11f_master_ball", world, GEN1_EVENT_GOT_MASTER_BALL, "EVENT_GOT_MASTER_BALL") + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_SILPH_CO_7F, "silph_11f_pad_to_7f", GEN1_SILPH_11F_PAD_TO_7F], + [GEN1_SILPH_CO_3F, "silph_7f_pad_to_3f", GEN1_SILPH_7F_PAD_TO_3F], + [GEN1_SILPH_CO_ELEVATOR, "silph_3f_elevator", GEN1_SILPH_3F_ELEVATOR], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_ride(path, world, save, random, data, "silph_car_to_1f", + GEN1_SILPH_CAR_PANEL, GEN1_SILPH_CAR_DOOR, GEN1_SILPH_CO_1F) + if not bool(stepped["ok"]): + return stepped + return _gen1_warp_legs(path, world, save, random, data, [[GEN1_SAFFRON_CITY, "silph_co_exit"]]) + + +## `PrintCardKeyText`: the door in front of [param cell] opened with the CARD KEY. +func _gen1_card_key( + path: Array, step: String, world: Gen2WorldAPI, cell: Vector2i, facing: int, + save: Gen2SaveData, random: RandomNumberGenerator, data: GameData +) -> Dictionary: + var opened: Dictionary = _gen1_talk(world, cell, facing, save, random, data) + var stepped: Dictionary = _gen1_step(path, step, world, opened) + if not bool(stepped["ok"]): + return stepped + var door: Vector2i = world.player_cell + world.facing_direction() + if not world.can_walk_to(door): + return {"ok": false, "path": path, "reason": "%s: the door at %s stayed shut" % [step, door]} + return {"ok": true} + + +## The gym's pads to Sabrina, and the MARSHBADGE. +func _gen1_saffron_gym_leg( + world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, + path: Array +) -> Dictionary: + var legs: Array = [[GEN1_SAFFRON_GYM, "saffron_gym_entry", GEN1_SAFFRON_GYM_DOOR]] + for pad: int in GEN1_SAFFRON_GYM_PADS.size(): + legs.append([GEN1_SAFFRON_GYM, "saffron_gym_pad_%d" % (pad + 1), GEN1_SAFFRON_GYM_PADS[pad]]) + var stepped: Dictionary = _gen1_warp_legs(path, world, save, random, data, legs) + if not bool(stepped["ok"]): + return stepped + var sabrina: Dictionary = _gen1_talk_to(world, GEN1_SABRINA, save, random, data) + stepped = _gen1_step(path, "saffron_gym_sabrina", world, sabrina, { + "party": _party_species(save), "items": _named_items(data, world.state.items()), + "badges": world.state.badge_count(), + }) + if not bool(stepped["ok"]): + return stepped + for flag: Array in [ + [GEN1_EVENT_BEAT_SABRINA, "EVENT_BEAT_SABRINA"], [GEN1_EVENT_GOT_TM46, "EVENT_GOT_TM46"], + ]: + stepped = _gen1_flag_leg(path, "saffron_gym_sabrina", world, int(flag[0]), String(flag[1])) + if not bool(stepped["ok"]): + return stepped + if not world.state.is_engine_flag_active(Gen2WorldState.gen1_badge_flag(GEN1_BIT_MARSHBADGE)): + return {"ok": false, "path": path, "reason": "saffron_gym_sabrina: MARSHBADGE is clear"} + return {"ok": true} From 1f67790e35fb5c2522659415f2b19f3713e01ac0 Mon Sep 17 00:00:00 2001 From: Decrypt <90518536+Decryptu@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:39:11 +0200 Subject: [PATCH 7/8] Walk Red, Blue and Yellow from Sabrina to the EARTHBADGE The story walk takes Silph Co.'s LAPRAS, teaches it SURF, sails Route 19 and Route 20 through the Seafoam Islands to Cinnabar, turns the Mansion's switches for the SECRET KEY, answers Blaine's six quiz machines, surfs Route 21 to Pallet and beats Giovanni in Viridian's gym: all eight badges on all three cartridges. CinnabarIsland_Script sets BIT_CUR_MAP_LOADED_1 on every frame and nothing on the island reads it, so gen1_map_load_pending() now answers only for a bit some callback of the map takes; the walk had never settled there. A hidden event's text row can sit past every object's, as the Mansion's four switches do, and the table was grown before the hidden rows were read, so the switches opened nothing. Cache format 131. gen1_maps refuses a hidden event naming a row that was not read and gen1_walk presses 3F's switch. Route 20's rock column at x = 62 parts its two seas below the islands' north field, so the sea from Fuchsia takes the west door; B3F's pool is crossed from its one $15 bank a side, TilePairCollisionsWater's CAVERN row keeping the $05 banks off it. A surfing walk plans over water alone until the cell it lands on, a dungeon hole is fallen through when a step ends on it, a same-map pad counts as arriving, and a cell whose script only moved the player off it is planned around next time. Yellow's Route 19 keeps two more rows of sand. --- README.md | 2 +- game/battle/ai.gd | 4 - game/battle/battle_anim_object.gd | 4 - game/gen1/gen1_world_importer.gd | 9 +- game/import/battle_anim_importer.gd | 4 - game/import/rom_cache.gd | 2 +- game/mods/mod_index.gd | 4 - game/render/pokedex_page.gd | 4 - game/world/link_session.gd | 4 - game/world/mystery_gift.gd | 4 - game/world/pokedex_screen.gd | 4 - game/world/town_map_screen.gd | 4 - game/world/world_api.gd | 16 +- game/world/world_bug_contest.gd | 3 - game/world/world_palette.gd | 3 - game/world/world_script.gd | 3 - tests/unit/test_source_budget.gd | 2 +- tools/checks/gen1_maps.gd | 131 +++++---- tools/checks/gen1_trainers.gd | 6 +- tools/checks/gen1_walk.gd | 51 ++++ tools/preview_world_story.gd | 423 +++++++++++++++++++++++++++- 21 files changed, 554 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index 6822902d..a23de025 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ The rest are previews and dumps, each driving a real screen or table: ```bash # the full walked route: Johto, the Hall of Fame, every Kanto gym, and Red godot --headless --path . -s res://tools/preview_world_story.gd -- crystal 24 7 2 2 1 none home story -# Red, Blue or Yellow from the bedroom to the MARSHBADGE +# Red, Blue or Yellow from the bedroom to the EARTHBADGE godot --headless --path . -s res://tools/preview_world_story.gd -- red 0 38 3 6 1 none home story ``` diff --git a/game/battle/ai.gd b/game/battle/ai.gd index be0dea9f..545ac974 100644 --- a/game/battle/ai.gd +++ b/game/battle/ai.gd @@ -2,7 +2,6 @@ class_name Gen2BattleAI extends RefCounted ## Scores an enemy trainer's move choice the way the cartridge's own AI does. -## ## Every slot starts at 20, or 80 with no PP. Each bit set in the trainer class's ## [constant Gen2Layout.ATTR_AI_MOVE_WEIGHTS] runs one scoring layer over the four ## slots, nudging scores up (discourage) or down (encourage); lowest wins, ties @@ -1259,7 +1258,6 @@ static func _smart_pursuit(scores: Array, slot: int, c: Context) -> void: ## `AI_Smart_Protect`: one ladder of tests, first match winning. -## static func _smart_protect(scores: Array, slot: int, c: Context) -> void: if c.attacker.protect_count != 0: _smart_protect_discourage(scores, slot, c, true) @@ -1398,7 +1396,6 @@ static func _apply_opportunist(scores: Array, c: Context) -> void: ## whichever deals the most, unless it would cost the mon itself ## ([constant RECKLESS_EFFECTS]) or does one point of damage that is really a ## fixed-damage move ([code]power < 2[/code]). -## ## The estimate is [method _estimate_damage], which is `AIDamageCalc` itself. static func _apply_aggressive(scores: Array, c: Context) -> void: var attacker: Gen2BattleMon = c.attacker @@ -1450,7 +1447,6 @@ static func _estimate_damage(c: Context, move: Dictionary, constant: bool = true ## [constant Gen2Layout.AI_CAUTIOUS]: discourage [constant RESIDUAL_MOVE_NUMBERS] ## once it is no longer the attacker's first turn. -## ## Diverges from a documented source bug (`docs/bugs_and_glitches.md`, ## "'Cautious' AI may fail to discourage residual moves") unless ## `cautious_ai_abandons_remaining_moves` is on: `ret nc` abandons the remaining diff --git a/game/battle/battle_anim_object.gd b/game/battle/battle_anim_object.gd index 31ac7265..bfe65250 100644 --- a/game/battle/battle_anim_object.gd +++ b/game/battle/battle_anim_object.gd @@ -5,7 +5,6 @@ extends RefCounted ## (engine/battle_anims/core.asm, helpers.asm). `anim_obj` names a row of ## `BattleAnimObjects`, which supplies the frameset, the motion callback, the ## palette and the graphics sheet, and a place to put it. -## ## Every field is a cartridge byte: coordinates wrap at 256, and an object walking ## off one side is that wrap rather than a clamp. `frame` starts at -1 because ## `GetBattleAnimFrame` increments before it reads. @@ -108,11 +107,9 @@ func active() -> bool: ## `BattleAnimOAMUpdate`, as the sprites it would write into `wShadowOAM`. -## ## Returns [code]{ deleted, sprites }[/code]. `sprites` is empty for an ## `oamwait`, which is a frame the object is simply not drawn on, and for the ## `oamdelete` that ends it; `deleted` says which of the two happened. -## ## [param enemy_turn] is `hBattleTurn`, the only thing that decides whether the ## coordinates are mirrored onto the other side of the field. func oam_update( @@ -207,7 +204,6 @@ func _attributes(attributes: int, flags: int, object_palette: int) -> int: ## `GetBattleAnimFrame`. Answers the OAM set to draw, or one of the three ## commands, and leaves [member _frame_flags] set for the frame it chose. -## ## While a frame still has duration left it is redrawn without advancing. ## `oamend` steps back two and loops, which redraws the last real frame forever; ## `oamrestart` goes back to -1 and loops, which starts the frameset again. diff --git a/game/gen1/gen1_world_importer.gd b/game/gen1/gen1_world_importer.gd index ecdcfa1e..595314e1 100644 --- a/game/gen1/gen1_world_importer.gd +++ b/game/gen1/gen1_world_importer.gd @@ -725,15 +725,18 @@ static func _read_map( var states: Dictionary = _read_map_states( rom, layout, bank, rom.u16le(header + MAP_SCRIPT_AT), texts ) + ## A hidden event's row can sit past every object's, as the Mansion's four + ## switches do, so the hidden rows are read before the table is grown. + var hidden: Array = _read_hidden_events( + rom, layout, map_id, bank, rom.u16le(header + MAP_SCRIPT_AT) + ) + events["hidden_events"] = hidden ## A row the table grew by may reach one higher still, which is how the ## Safari Zone gate's own six are read rather than its first four. while _extend_texts(rom, layout, bank, rom.u16le(header + 5), texts, events, states, known): states = _read_map_states( rom, layout, bank, rom.u16le(header + MAP_SCRIPT_AT), texts ) - var hidden: Array = _read_hidden_events( - rom, layout, map_id, bank, rom.u16le(header + MAP_SCRIPT_AT) - ) for row: Dictionary in hidden: _bind_map_script_byte(row.get("script", []) as Array, int(states["byte"])) diff --git a/game/import/battle_anim_importer.gd b/game/import/battle_anim_importer.gd index 8bc1a2db..7894a5d4 100644 --- a/game/import/battle_anim_importer.gd +++ b/game/import/battle_anim_importer.gd @@ -5,7 +5,6 @@ extends RefCounted ## in data/battle_anims the objects it spawns are built from. Each is a pointer ## table immediately followed by its data with every pointer bank-local, so each ## is cached as a whole region and a cached address resolves by subtraction. -## ## Validation walks what it stores, which is what makes a wrong offset a refused ## import rather than a screen full of noise. The object graphics are the ## exception: LZ streams reached by far pointer, decoded into tile strips. @@ -163,7 +162,6 @@ static func read_battle_anims(rom: RomFile, layout: Dictionary) -> Dictionary: ## `BattleAnimations` and every body it reaches, as one region. -## ## The walk follows calls and branches the way [Gen2BattleAnimScript] does, so a ## body only some other body jumps into is still measured, and refuses anything ## that leaves the bank or never reaches a top-level `anim_ret`. @@ -271,7 +269,6 @@ static func _walk_body(bank_bytes: PackedByteArray, address: int) -> Dictionary: ## `BattleAnimSineWave`, the table `BattleAnim_Sine` reads. -## ## The bytes are the check as well as the content: 64 identical bytes appear ## several times in a dump, so the offset is only trustworthy against the values, ## and this is the one copy in the animation bank. @@ -383,7 +380,6 @@ static func _read_framesets(rom: RomFile, table: int) -> Dictionary: ## One `oamframe` stream, to whichever of the three terminators ends it. -## ## An `oamframe` and an `oamwait` are both two bytes, because `.GetPointer` ## indexes frames by `frame * 2`. The three terminators are one byte each in the ## macros and the byte after them belongs to the next frameset, which the diff --git a/game/import/rom_cache.gd b/game/import/rom_cache.gd index d94c726d..c190bd34 100644 --- a/game/import/rom_cache.gd +++ b/game/import/rom_cache.gd @@ -77,7 +77,7 @@ const BYTES_KEY: String = "bytes" ## a dump the owner still has, so re-importing costs a few seconds and a ## migration would have to carry every past shape forever. Nothing but the cache ## is thrown away, since saves live under their own root. -const FORMAT_VERSION: int = 130 +const FORMAT_VERSION: int = 131 ## What [method state] answers. A stale cache is told from a missing one because ## they need different things said to whoever is looking at it: one is a diff --git a/game/mods/mod_index.gd b/game/mods/mod_index.gd index 764bfff1..ecdfc312 100644 --- a/game/mods/mod_index.gd +++ b/game/mods/mod_index.gd @@ -49,7 +49,6 @@ const NOT_INSTALLED: StringName = &"not_installed" ## Turns whatever the player pasted into the feed URL to read. -## ## People copy whichever URL they happen to be looking at, so a repository page, ## a Pages site, a bare `owner/repo` and the feed file itself all resolve to the ## same place. Returns { ok, feed, label } or { ok: false, reason }. @@ -79,7 +78,6 @@ static func resolve_source(input: String) -> Dictionary: ## Reads a fetched feed into rows the launcher can list. -## ## Returns { ok, name, entries } where each entry has id, name, version, ## description, download, games, and the optional icon and thumbnail URLs. An ## entry missing an id or a usable download is dropped rather than failing the whole @@ -207,7 +205,6 @@ static func cached_feed(feed: String, directory: String = CACHE_DIRECTORY) -> Di ## What a fetch of [param feed] amounts to, whether or not it arrived. -## ## A feed that parsed is kept and answered; anything else falls back to the copy ## already on disk, so a server that is down costs the freshness of a listing ## rather than the listing. `stale` is true for the fallback, with `age` in @@ -331,7 +328,6 @@ static func _label_for(url: String) -> String: ## Whether [param feed] is the one this project publishes, which is followed by ## every build and cannot be dropped. -## ## Not `is_built_in`: [method Script.is_built_in] takes no arguments and a class ## name is a [Script], so that spelling resolves to Godot's and answers false ## everywhere without an error. diff --git a/game/render/pokedex_page.gd b/game/render/pokedex_page.gd index da26ad39..da1151d6 100644 --- a/game/render/pokedex_page.gd +++ b/game/render/pokedex_page.gd @@ -265,7 +265,6 @@ static func blank_map() -> PackedInt32Array: ## `Pokedex_DrawMainScreenBG`: the left sidebar, the two boxes and the bottom ## bar. The listing is not in it; that is the window, and [method window_map] ## draws it. -## ## [param seen] and [param caught] are the two counts `CountSetBits` prints. func main_background(seen: int, caught: int) -> PackedInt32Array: _unown_letters = false @@ -317,7 +316,6 @@ func search_results_background(count: int, type_line: String) -> PackedInt32Arra ## `DrawPokedexListWindow` and `Pokedex_PrintListing`, as the twelve-column ## window the main screen and the results screen both put their listing in. -## ## [param rows] is [method Gen2Pokedex.rows]' own shape: `number`, `name`, ## `seen` and `caught` per visible entry. [param old_mode] swaps the scroll bar ## for the two tiles that replace it and is what prints a dex number above each @@ -402,7 +400,6 @@ func results_window_map(rows: Array) -> PackedInt32Array: ## `Pokedex_DrawDexEntryScreenBG` and `DisplayDexEntry`, as one grid. -## ## [param entry] is [method GameData.dex_entry]'s own record. A species that has ## not been caught keeps the placeholder height and weight the background draws, ## which is what the source's early `ret z` leaves on screen. @@ -529,7 +526,6 @@ func search_map( ## `Pokedex_DrawUnownModeBG` and its own letter walk. -## ## [param forms] is `wUnownDex`, the forms in catching order, and ## [constant UNOWN_COORDS] the table it indexes: the letter's cell and the cell ## the cursor stands in beside it, which are not always the same column. diff --git a/game/world/link_session.gd b/game/world/link_session.gd index 67c5a1ca..92d70335 100644 --- a/game/world/link_session.gd +++ b/game/world/link_session.gd @@ -102,7 +102,6 @@ func wait_for_linked_friend(transport: Gen2LinkTransport) -> int: ## `Link_CheckCommunicationError`: FALSE is a link that dropped while the two ## players were saving, and the peer's own action lands in ## `wOtherPlayerLinkMode` for the `readmem` that follows. -## ## A Gen 1 game has no `wPlayerLinkAction` to raise, so what comes back from one ## is zero, which is the whole of the "can't link to the past" branch and the ## whole of what makes the Time Capsule legal. @@ -188,7 +187,6 @@ func which_chris(transport: Gen2LinkTransport) -> int: ## tests: every species slot first, then mail, then moves. The answer is the ## wScriptVar value and the party slot the box names, which is the slot the test ## stopped on rather than the first slot of the party. -## ## [param party] is the world's own party mirror: the parallel `species`, ## `held_items` and `moves` arrays [method Gen2WorldAPI.set_party_summary] ## carries, which is where every other party-reading special gets its answer. @@ -252,7 +250,6 @@ static func validate_ot_trademon( ## `CheckAnyOtherAliveMonsForTrade`. A trade that would leave this side with ## nothing that can fight is refused, so the offered slot is skipped here and ## the incoming Pokemon is what answers for it. -## ## Carry set is the refusal in the source, so this answers the opposite: TRUE ## means the trade may go ahead. static func any_other_alive_mons_for_trade( @@ -271,7 +268,6 @@ static func any_other_alive_mons_for_trade( ## takes the last of the five, which `.FindOpponentAndAppendRecord`'s sort has ## kept for the least successful one. Both counters stop at ## [constant MAX_LINK_RECORD] rather than wrapping. -## ## [param result] is `wins`, `losses` or `draws`, which is `wBattleResult`'s own ## WIN/LOSE/DRAW one name further on. static func add_battle_to_record( diff --git a/game/world/mystery_gift.gd b/game/world/mystery_gift.gd index a09898e6..059924cf 100644 --- a/game/world/mystery_gift.gd +++ b/game/world/mystery_gift.gd @@ -65,7 +65,6 @@ const LOCKED: int = 0xFF ## The section a slot that has never seen a Mystery Gift carries. -## ## `sMysteryGiftData` is two pairs rather than one block, and the pairing is ## what `BackupMysteryGift` and `RestoreMysteryGift` copy between: `item` and ## `unlocked` are the working bytes the exchange writes, `backup_item` and @@ -213,7 +212,6 @@ static func copy_decorations_to_pc( ## `MysteryGiftGetItem` and `MysteryGiftGetDecoration`, which share ## `MysteryGiftFallbackItem`: an index past either table's end is a RED CARPET ## as a decoration and a GREAT BALL as an item, the same byte read two ways. -## ## [param table] is the imported `MysteryGiftItems` or `MysteryGiftDecos`. static func gift_at(table: Array, index: int) -> int: if index < 0 or index >= table.size(): @@ -349,7 +347,6 @@ static func _add_partner_id(section: Dictionary, id: int) -> void: ## `.SaveMysteryGiftTrainerName`, which is what the Trainer House reads: the ## partner's name and the flag that says somebody has linked. -## ## The party beside them is deliberately empty, because a real cartridge's is: ## `StagePartyDataForMysteryGift` sits behind a `vc_patch` and is unreferenced ## in the retail build, so `ClearMysteryGiftTrainer` zeroes `wMysteryGiftTrainer` @@ -369,7 +366,6 @@ static func _item_name(data: GameData, item: int) -> String: ## `DoMysteryGiftIfDayHasPassed`, which is the only thing that resets the day's ## partner list and runs when the menu row is chosen rather than at midnight. -## ## `sMysteryGiftTimer` is `InitOneDayCountdown`'s own two bytes: the days still ## to run, and the day the countdown started on. `CheckDayDependentEventHL` ## takes the days since that start off the first byte and reports the borrow, diff --git a/game/world/pokedex_screen.gd b/game/world/pokedex_screen.gd index 0aa86828..02efac10 100644 --- a/game/world/pokedex_screen.gd +++ b/game/world/pokedex_screen.gd @@ -232,7 +232,6 @@ func _handle_list(button: int) -> bool: ## `Pokedex_UpdateDexEntryScreen`: B returns to the listing, A turns the page, ## and up and down step to the neighbouring entry. -## ## The source's four-button row is PAGE, AREA, CRY and PRNT; only PAGE is built, ## so A always turns the page rather than moving a cursor along a row whose ## other three entries would refuse. @@ -377,7 +376,6 @@ func _handle_option(button: int) -> bool: ## `.ChangeMode`, including the message it shows while the order is rebuilt. ## Choosing the mode already in use returns to the listing untouched. -## ## UNOWN is not one of them: `.MenuAction_UnownMode` never writes `wCurDexMode`, ## it jumps straight to DEXSTATE_UNOWN_MODE, so the listing keeps the mode it ## had and the Unown screen answers back to OPTION rather than to the listing. @@ -640,7 +638,6 @@ func _handle_search_results(button: int) -> bool: ## `Pokedex_InitSearchScreen`, which resets both type rows every time. -## ## Coming back from the results screen resets them too: `.return_to_search_screen` ## jumps to DEXSTATE_SEARCH_SCR, and that jumptable entry is this Init rather ## than its Update, so the search is not remembered. @@ -806,7 +803,6 @@ func _selected_pic() -> Image: ## One imported pic, or the question mark when the cache does not hold it. -## ## `_CGB_Pokedex` fills the picture box's attrmap with palette 1, and which ## palette that is turns on `wCurPartySpecies`: the two listing screens set it to ## `-1` and get `PokedexQuestionMarkPalette`, so every species is drawn in the diff --git a/game/world/town_map_screen.gd b/game/world/town_map_screen.gd index a70031cd..e8a4f7e2 100644 --- a/game/world/town_map_screen.gd +++ b/game/world/town_map_screen.gd @@ -114,7 +114,6 @@ func _ready() -> void: ## region map: `_TownMap`'s own corner box or the Pokegear card's icon row. ## [param cards] is the owned `wPokegearFlags` cards, which only the card frame ## reads. -## ## Optional the way the other overlays are: a cache with no region map answers ## false and the caller keeps its menu open. func open( @@ -207,7 +206,6 @@ func open_dex_area( ## visited. [param in_kanto] is which map `FlyMap` opens, which is the region the ## player is standing in, and [param visited] which `FLY_*` indexes ## `CheckIfVisitedFlypoint` answers for. -## ## The answer is taken with [method chosen_spawn] once this closes: -1 for a ## cancel, and the flypoint's own spawn for a choice, which is exactly the byte ## `.pressedA` leaves in `e`. @@ -259,7 +257,6 @@ func cursor_name() -> String: ## `.loop`'s own joypad read: B leaves and the d-pad walks the window. Every ## other button is swallowed, which is what the loop does with them. -## ## The dex area's loop leaves on A as well as B, walks regions rather than ## landmarks, and reads SELECT as a held state; see [method release_button]. func handle_button(button: int) -> bool: @@ -309,7 +306,6 @@ func close() -> void: ## One hardware frame. Only the player icon moves: the cursor's own ## `SPRITEANIMSTRUCT_ANIM_SEQ_ID` is overwritten with `SPRITE_ANIM_FUNC_NULL`, so ## it holds `.Frameset_StillCursor`'s single entry forever. -## ## The dex area animates nothing. Its own frame is `.BlinkNestIcons`, which ## shows the icons for sixteen frames and hides them for sixteen; the player ## icon it draws instead is `GetPlayerIcon`'s standing frame, not a walk. diff --git a/game/world/world_api.gd b/game/world/world_api.gd index 02586106..7f5eac91 100644 --- a/game/world/world_api.gd +++ b/game/world/world_api.gd @@ -7608,14 +7608,24 @@ func _queue_map_callbacks(callback_type: int) -> void: func _gen1_map_script_nodes(mask: int) -> Array: if mask & (1 << Gen1Layout.MAP_LOADED_1_BIT): _unlock_gen1_card_key_door() + var callback: Dictionary = _gen1_map_load_callback(mask) + return callback["nodes"] as Array if not callback.is_empty() \ + else current_map.scripts.get("entry", []) as Array + + +func _gen1_map_load_callback(mask: int) -> Dictionary: for callback: Dictionary in current_map.scripts.get("callbacks", []) as Array: if int(callback.get("mask", 0)) == mask: - return callback["nodes"] as Array - return current_map.scripts.get("entry", []) as Array + return callback + return {} +## Whether the next pass owes the map a body its own script does not run every +## frame. `CinnabarIsland_Script` sets BIT_CUR_MAP_LOADED_1 on every frame and +## nothing on the island reads it, so a bit no callback takes is not pending. func gen1_map_load_pending() -> bool: - return _gen1_map_load_pending != 0 + return _gen1_map_load_pending != 0 and current_map != null \ + and not _gen1_map_load_callback(_gen1_map_load_pending).is_empty() ## Spends every step of [param nodes] that takes no turn of its own, stopping diff --git a/game/world/world_bug_contest.gd b/game/world/world_bug_contest.gd index e698f123..01d3114b 100644 --- a/game/world/world_bug_contest.gd +++ b/game/world/world_bug_contest.gd @@ -4,7 +4,6 @@ extends RefCounted ## The Bug Catching Contest: its own encounter roll, its score, and its judging ## (`engine/events/bug_contest/`, `engine/overworld/events.asm`'s ## `TryWildEncounter_BugContest` and `ChooseWildEncounter_BugContest`). -## ## Scene-free and stateless, like [Gen2WorldTreemon]: the tables are the cache's ## (`GameData.bug_contest_mons`, `GameData.bug_contestants`), the live counters ## are [Gen2WorldState]'s, and every roll takes the caller's generator so a @@ -137,7 +136,6 @@ static func _level(row: Dictionary, random: RandomNumberGenerator) -> int: ## `ContestScore`: the tally the player's caught Pokemon is judged on. Every ## term is the *low* byte of a big-endian word, which is the stat itself for ## anything a contest Pokemon can reach, and the whole runs 16 bit with carry. -## ## [param mon] is the caught Pokemon as [Gen2WorldState] keeps it: ## `max_hp`, `hp`, `attack`, `defense`, `speed`, `special_attack`, ## `special_defense`, `dvs` and `item`. @@ -174,7 +172,6 @@ static func _dv_score(dvs: int) -> int: ## `BugContest_JudgeContestants`: the AI contestants are scored first and the ## player is inserted last, which is why the player takes a place on a tie. -## ## [param withdrawn] is the set of contestant indices whose event flag is set, ## which `SelectRandomBugContestContestants` chose and who therefore do not ## compete. Answers `{"placings": [...], "player_place": 0..3}` with the placings diff --git a/game/world/world_palette.gd b/game/world/world_palette.gd index 9dc8de73..7d9562d6 100644 --- a/game/world/world_palette.gd +++ b/game/world/world_palette.gd @@ -36,7 +36,6 @@ const PALETTE_MASK: int = 0x07 ## Which of the four palette rows a map draws with right now. -## ## `ReplaceTimeOfDayPals` and `GetTimePalette` together. A ## [constant PALETTE_DARK] map is the only one the clock cannot reach: it is ## [constant TIME_DARK] until Flash has been used and [constant TIME_NIGHT] @@ -173,7 +172,6 @@ static func palette_slots(environment: int, time_of_day: int) -> Array: ## The two colours a map group lends `PAL_BG_ROOF`, or nothing when the map is ## not one `_LoadMapPals` reaches the roof branch for. -## ## `cp NITE_F / jr c, .morn_day` is the whole test, so DARKNESS takes the nite ## pair as well: the four rows are two, not four. static func roof_colors( @@ -250,7 +248,6 @@ static func _flood_white(resolved: Array) -> void: ## One palette per tile of [param tileset], in tile order. -## ## Every tile of the strip shares eight palette slots, so the eight are resolved ## once and the same one is handed to every tile that uses it. This runs again on ## every frame an animated tileset changes a tile, and building one palette copy diff --git a/game/world/world_script.gd b/game/world/world_script.gd index 32636675..e1248484 100644 --- a/game/world/world_script.gd +++ b/game/world/world_script.gd @@ -2,7 +2,6 @@ class_name Gen2WorldScript extends RefCounted ## Shared Generation 2 overworld script command definitions. -## ## The cartridge stores one command byte followed by command-specific operands. ## This file describes the byte layout for the commands used by the bounded ## overworld runner. Unknown commands remain visible to the caller instead of @@ -554,7 +553,6 @@ const NON_RETURNING_SOURCE_OPCODES: Array[int] = [ ## Whether the command at [param opcode] is followed by another command. -## ## A linear walk over a script stops here, not at [method is_terminal]: a script ## that ends in `jumptext` is followed by the text it named, and a `jumpstd` ## table by its pointers. Only the live runner, which dispatches one command at @@ -945,7 +943,6 @@ static func decode_command_queue_entry(data: PackedByteArray) -> Dictionary: ## Decodes a `stonetable`, the only cmdqueue payload either game ships. -## ## Rows are `warp_id, object_id, script` until a $ff warp id, and the ids are ## the source's own: the warp is one-based, as `.check_on_warp` counts it, and ## the object is an `object_const_def` constant, which starts at 2, so it is two diff --git a/tests/unit/test_source_budget.gd b/tests/unit/test_source_budget.gd index 89cabc2a..bda0aa50 100644 --- a/tests/unit/test_source_budget.gd +++ b/tests/unit/test_source_budget.gd @@ -21,7 +21,7 @@ const MAX_COMMENT_BLOCK: int = 8 ## the shared code, the largest of them 152 the region map, 150 the battle ## animation engine, 140 the transition, 131 the field moves, 130 the Pokedex, ## 108 the three PCs, 90 the Safari Zone, 52 the menus a row draws. -const MAX_COMMENT_LINES: int = 41939 +const MAX_COMMENT_LINES: int = 41935 ## What no comment block ever ends on. A pass that meets the ceiling above trims ## the second line of a two-line block and leaves the first mid-sentence, which diff --git a/tools/checks/gen1_maps.gd b/tools/checks/gen1_maps.gd index 3ae46e59..1ee51c5e 100644 --- a/tools/checks/gen1_maps.gd +++ b/tools/checks/gen1_maps.gd @@ -115,9 +115,9 @@ const PALETTE_CENSUS: Dictionary = { ## byte that opens it. Yellow's six bare `text_end`s are Jessie and James, whose ## two ids share one on three maps. const TEXT_CENSUS: Dictionary = { - &"red": {0x08: 638, 0x17: 528, 0xFF: 12, 0xF6: 12, 0xFE: 14, 0xF5: 3, 0xF7: 3}, - &"blue": {0x08: 638, 0x17: 528, 0xFF: 12, 0xF6: 12, 0xFE: 14, 0xF5: 3, 0xF7: 3}, - &"yellow": {0x08: 691, 0x17: 512, 0xFF: 12, 0xF6: 12, 0xFE: 14, 0x50: 6, 0xF5: 3, 0xF7: 3}, + &"red": {0x08: 642, 0x17: 528, 0xFF: 12, 0xF6: 12, 0xFE: 14, 0xF5: 3, 0xF7: 3}, + &"blue": {0x08: 642, 0x17: 528, 0xFF: 12, 0xF6: 12, 0xFE: 14, 0xF5: 3, 0xF7: 3}, + &"yellow": {0x08: 695, 0x17: 512, 0xFF: 12, 0xF6: 12, 0xFE: 14, 0x50: 6, 0xF5: 3, 0xF7: 3}, } ## Rows of `script_mart` across the corpus. Yellow's Celadon 5F clerk sells one @@ -129,54 +129,52 @@ const EMPTY_TEXTS: Dictionary = {&"red": 1, &"blue": 1, &"yellow": 1} ## The `text_asm` rows read as a script, and the nodes under them. const SCRIPT_CENSUS: Dictionary = { - &"red": {"rows": 318, "text": 609, "branch": 158, "choice": 41, "flag": 246, - "give_item": 44, "has_item": 20, "take_item": 11, "unknown": 0, "pokedex": 13, - "give_pokemon": 33, "saved_coord_index": 1, "badges_byte": 1, "walk": 20, - "player_facing": 11, "set_map_script": 113, "npc_movement_script": 2, - "toggle_object": 49, "trainer_battle_object": 21, "random": 5, "facing": 12, - "player_in_array": 1, "flag_test": 3, "pick_up_item": 105, "scratch": 37, - "name_badge": 7, "heal_party": 2, "object_facing": 9, "talking_to": 32, - "set_starter": 9, "name_species": 14, "dex_rating": 2, "dex_count": 2, - "map_text": 24, "trade": 9, "name_item": 7, "oaks_aide": 3, "player_coord": 4, - "money_box": 6, "has_money": 4, "spend_money": 4, "menu": 3, "menu_cancel": 3, + &"red": {"rows": 322, "text": 621, "branch": 162, "choice": 45, "flag": 254, "give_item": 44, + "has_item": 20, "take_item": 11, "unknown": 0, "pokedex": 13, "give_pokemon": 33, + "saved_coord_index": 1, "emote": 1, "player_facing": 11, "badges_byte": 1, "walk": 20, + "set_map_script": 113, "npc_movement_script": 2, "toggle_object": 49, + "trainer_battle_object": 21, "random": 5, "facing": 12, "player_in_array": 1, + "flag_test": 7, "pick_up_item": 105, "scratch": 37, "name_badge": 7, "heal_party": 2, + "object_facing": 9, "talking_to": 32, "set_starter": 9, "name_species": 14, + "dex_rating": 2, "dex_count": 2, "map_text": 24, "trade": 9, "name_item": 7, + "oaks_aide": 3, "player_coord": 4, "money_box": 6, "has_money": 4, "spend_money": 4, + "menu": 3, "menu_cancel": 3, "menu_row": 1, "guard_drink": 4, "day_care": 1, + "random_bit": 2, "volatile": 2, "filtered_bag": 2, "menu_item": 4, "elevator": 3, + "diploma": 1, "coin_box": 2, "has_coins": 4, "add_coins": 4, "replace_block": 1, + "starter": 2, "trainer_battle": 3, "safari_balls": 1, "safari_steps": 1, + "save_coord_index": 2, "map_load_bit": 6, "copy_name": 4, "set_fossil": 6, + "party_menu": 1, "name_party_mon": 1, "mon_ot": 1, "name_mon": 1, "list_menu": 1}, + &"blue": {"rows": 322, "text": 621, "branch": 162, "choice": 45, "flag": 254, "give_item": 44, + "has_item": 20, "take_item": 11, "unknown": 0, "pokedex": 13, "give_pokemon": 33, + "saved_coord_index": 1, "emote": 1, "player_facing": 11, "badges_byte": 1, "walk": 20, + "set_map_script": 113, "npc_movement_script": 2, "toggle_object": 49, + "trainer_battle_object": 21, "random": 5, "facing": 12, "player_in_array": 1, + "flag_test": 7, "pick_up_item": 105, "scratch": 37, "name_badge": 7, "heal_party": 2, + "object_facing": 9, "talking_to": 32, "set_starter": 9, "name_species": 14, + "dex_rating": 2, "dex_count": 2, "map_text": 24, "trade": 9, "name_item": 7, + "oaks_aide": 3, "player_coord": 4, "money_box": 6, "has_money": 4, "spend_money": 4, + "menu": 3, "menu_cancel": 3, "menu_row": 1, "guard_drink": 4, "day_care": 1, + "random_bit": 2, "volatile": 2, "filtered_bag": 2, "menu_item": 4, "elevator": 3, + "diploma": 1, "coin_box": 2, "has_coins": 4, "add_coins": 4, "replace_block": 1, + "starter": 2, "trainer_battle": 3, "safari_balls": 1, "safari_steps": 1, + "save_coord_index": 2, "map_load_bit": 6, "copy_name": 4, "set_fossil": 6, + "party_menu": 1, "name_party_mon": 1, "mon_ot": 1, "name_mon": 1, "list_menu": 1}, + &"yellow": {"rows": 378, "text": 625, "branch": 164, "choice": 38, "flag": 232, "give_item": 41, + "has_item": 15, "take_item": 10, "unknown": 3, "pokedex": 10, "give_pokemon": 9, + "saved_coord_index": 2, "player_facing": 16, "emote": 8, "badges_byte": 1, "walk": 23, + "set_map_script": 100, "npc_movement_script": 2, "toggle_object": 22, + "trainer_battle_object": 27, "random": 5, "facing": 12, "player_in_array": 1, + "name_species": 8, "flag_test": 10, "pick_up_item": 109, "scratch": 29, + "name_badge": 7, "heal_party": 2, "dex_rating": 2, "dex_count": 6, "set_starter": 1, + "map_text": 24, "trade": 7, "name_item": 7, "oaks_aide": 3, "player_coord": 4, + "money_box": 6, "has_money": 5, "spend_money": 4, "menu": 3, "menu_cancel": 3, "menu_row": 1, "guard_drink": 4, "day_care": 1, "random_bit": 2, "volatile": 2, - "filtered_bag": 2, "menu_item": 4, "elevator": 3, "coin_box": 2, "has_coins": 4, - "add_coins": 4, "replace_block": 1, "starter": 2, "trainer_battle": 3, - "safari_balls": 1, "safari_steps": 1, "save_coord_index": 2, "map_load_bit": 2, - "copy_name": 4, "set_fossil": 6, "party_menu": 1, "name_party_mon": 1, "mon_ot": 1, - "name_mon": 1, "list_menu": 1, "emote": 1, "diploma": 1}, - &"blue": {"rows": 318, "text": 609, "branch": 158, "choice": 41, "flag": 246, - "give_item": 44, "has_item": 20, "take_item": 11, "unknown": 0, "pokedex": 13, - "give_pokemon": 33, "saved_coord_index": 1, "badges_byte": 1, "walk": 20, - "player_facing": 11, "set_map_script": 113, "npc_movement_script": 2, - "toggle_object": 49, "trainer_battle_object": 21, "random": 5, "facing": 12, - "player_in_array": 1, "flag_test": 3, "pick_up_item": 105, "scratch": 37, - "name_badge": 7, "heal_party": 2, "object_facing": 9, "talking_to": 32, - "set_starter": 9, "name_species": 14, "dex_rating": 2, "dex_count": 2, - "map_text": 24, "trade": 9, "name_item": 7, "oaks_aide": 3, "player_coord": 4, - "money_box": 6, "has_money": 4, "spend_money": 4, "menu": 3, "menu_cancel": 3, - "menu_row": 1, "guard_drink": 4, "day_care": 1, "random_bit": 2, "volatile": 2, - "filtered_bag": 2, "menu_item": 4, "elevator": 3, "coin_box": 2, "has_coins": 4, - "add_coins": 4, "replace_block": 1, "starter": 2, "trainer_battle": 3, - "safari_balls": 1, "safari_steps": 1, "save_coord_index": 2, "map_load_bit": 2, - "copy_name": 4, "set_fossil": 6, "party_menu": 1, "name_party_mon": 1, "mon_ot": 1, - "name_mon": 1, "list_menu": 1, "emote": 1, "diploma": 1}, - &"yellow": {"rows": 374, "text": 613, "branch": 160, "choice": 34, "flag": 224, - "give_item": 41, "has_item": 15, "take_item": 10, "unknown": 3, "pokedex": 10, - "give_pokemon": 9, "saved_coord_index": 2, "player_facing": 16, "emote": 8, - "badges_byte": 1, "walk": 23, "set_map_script": 100, "npc_movement_script": 2, - "toggle_object": 22, "trainer_battle_object": 27, "random": 5, "facing": 12, - "player_in_array": 1, "name_species": 8, "flag_test": 6, "pick_up_item": 109, - "scratch": 29, "name_badge": 7, "heal_party": 2, "dex_rating": 2, "dex_count": 6, - "set_starter": 1, "map_text": 24, "trade": 7, "name_item": 7, "oaks_aide": 3, - "player_coord": 4, "money_box": 6, "has_money": 5, "spend_money": 4, "menu": 3, - "menu_cancel": 3, "menu_row": 1, "guard_drink": 4, "day_care": 1, "random_bit": 2, - "volatile": 2, "filtered_bag": 2, "menu_item": 4, "elevator": 3, "diploma": 1, - "coin_box": 2, "has_coins": 4, "add_coins": 4, "replace_block": 1, - "trainer_battle": 1, "safari_balls": 3, "safari_steps": 3, "safari_admission": 2, - "save_coord_index": 2, "map_load_bit": 2, "talking_to": 14, "volatile_test": 6, - "copy_name": 4, "set_fossil": 6, "party_menu": 1, "name_party_mon": 1, "mon_ot": 1, - "name_mon": 1, "list_menu": 1}, + "filtered_bag": 2, "menu_item": 4, "elevator": 3, "diploma": 1, "coin_box": 2, + "has_coins": 4, "add_coins": 4, "replace_block": 1, "trainer_battle": 1, + "safari_balls": 3, "safari_steps": 3, "safari_admission": 2, "save_coord_index": 2, + "map_load_bit": 6, "talking_to": 14, "volatile_test": 6, "copy_name": 4, + "set_fossil": 6, "party_menu": 1, "name_party_mon": 1, "mon_ot": 1, "name_mon": 1, + "list_menu": 1}, } ## `SilphCo11FPorygonText` is a `call DisplayPokedex` the disassembly marks ## unreferenced. The `trade` rows are the eight `predef DoInGameTradeDialogue` @@ -828,22 +826,33 @@ func _walk_hidden( for node: Dictionary in nodes: var op: String = String(node["op"]) census[op] = int(census.get(op, 0)) + 1 - if op == "facing" and not Gen1Layout.FACING_STEPS.has(int(node["facing"])) \ - and wrong.size() < 4: - wrong.append("map %d faces %d" % [number, int(node["facing"])]) - if op == "text" and String(node["text"]).is_empty() and wrong.size() < 4: - wrong.append("map %d prints nothing" % number) - if op == "text" and String(node["text"]).contains(Gen2TextStream.NUMBER_MARKER) \ - and wrong.size() < 4: - wrong.append("map %d prints a number nothing wrote" % number) - if (op == "give_item" or op == "has_item" or op == "name_item") \ - and _r.data.item_name(int(node["item"])).is_empty(): - wrong.append("map %d names item %d" % [number, int(node["item"])]) + var fault: String = _hidden_node_fault(node, number) + if not fault.is_empty() and wrong.size() < 4: + wrong.append("map %d %s" % [number, fault]) for side: String in Gen1Layout.SCRIPT_BRANCH_KEYS: if node.has(side): _walk_hidden(node[side] as Array, census, wrong, number) +## What is wrong with one hidden event node, or "". +func _hidden_node_fault(node: Dictionary, number: int) -> String: + var op: String = String(node["op"]) + if op == "facing" and not Gen1Layout.FACING_STEPS.has(int(node["facing"])): + return "faces %d" % int(node["facing"]) + if op == "text" and String(node["text"]).is_empty(): + return "prints nothing" + if op == "text" and String(node["text"]).contains(Gen2TextStream.NUMBER_MARKER): + return "prints a number nothing wrote" + ## The Mansion's switches name rows past every object's, read all the same. + if op == "map_text" and node.has("text") \ + and (_maps[number] as Gen2WorldMap).text_at(int(node["text"])).is_empty(): + return "opens text %d, which was not read" % int(node["text"]) + if op in ["give_item", "has_item", "name_item"] \ + and _r.data.item_name(int(node["item"])).is_empty(): + return "names item %d" % int(node["item"]) + return "" + + ## `LavenderTownLittleGirlText`: the question, then `wCurrentMenuItem` zero for ## YES. Its `ld hl` between `and a` and the `jr nz` is why a branch is decoded ## off the flags rather than off the instruction in front of it. diff --git a/tools/checks/gen1_trainers.gd b/tools/checks/gen1_trainers.gd index 5b0178c7..a42717ad 100644 --- a/tools/checks/gen1_trainers.gd +++ b/tools/checks/gen1_trainers.gd @@ -15,9 +15,9 @@ const CLASS_COUNT: int = 47 ## `text_asm` rows, the ones reaching `TalkToTrainer`, and the objects naming ## one: a trainer class above `OPP_ID_OFFSET`, or a standing wild below it. const HEADER_CENSUS: Dictionary = { - &"red": {"text_asm": 638, "headers": 322, "trainers": 310, "wilds": 12, "coded": 3}, - &"blue": {"text_asm": 638, "headers": 322, "trainers": 310, "wilds": 12, "coded": 3}, - &"yellow": {"text_asm": 691, "headers": 317, "trainers": 305, "wilds": 12, "coded": 3}, + &"red": {"text_asm": 642, "headers": 322, "trainers": 310, "wilds": 12, "coded": 3}, + &"blue": {"text_asm": 642, "headers": 322, "trainers": 310, "wilds": 12, "coded": 3}, + &"yellow": {"text_asm": 695, "headers": 317, "trainers": 305, "wilds": 12, "coded": 3}, } ## The header texts whose machine code does more than print, by map and the ## flag each sets; Yellow moved the LIFT KEY's `ShowObject` to the end text. diff --git a/tools/checks/gen1_walk.gd b/tools/checks/gen1_walk.gd index d5e87317..1aa6899b 100644 --- a/tools/checks/gen1_walk.gd +++ b/tools/checks/gen1_walk.gd @@ -517,6 +517,8 @@ func _one_game() -> void: _check_the_captains_back() _check_the_ship_leaves() _check_the_gate_pushes_back() + _check_cinnabar_settles() + _check_a_mansion_switch() ## `DisplayPokemonCenterDialogue_` walked whole. `AnimateHealingMachine` is a @@ -3338,6 +3340,55 @@ func _check_the_viridian_gym_door() -> void: _r.note("gen1 walk VIRIDIAN_CITY's gym door with and without seven badges") +const CINNABAR_ISLAND: int = 8 +const CINNABAR_SHORE := Vector2i(19, 13) +const POKEMON_MANSION_3F: int = 215 +const MANSION_3F_SWITCH := Vector2i(10, 6) +const MANSION_3F_DOOR := Vector2i(15, 10) +const MANSION_SWITCH_FLAG: int = 632 +const MANSION_SWITCH_ASKS: String = "A secret switch!" + + +## `CinnabarIsland_Script` sets BIT_CUR_MAP_LOADED_1 on every frame and nothing +## on the island reads it, so a walk there is not forever owed a load pass. +func _check_cinnabar_settles() -> void: + var world: Gen2WorldAPI = _r.open_world(0, CINNABAR_ISLAND, CINNABAR_SHORE) + if world == null: + return + world.dispatch_map_entry() + for _pass: int in 3: + world.dispatch_sight_events() + _r.check(not world.gen1_map_load_pending(), + "Cinnabar Island still owes a map-load pass after three frames.") + _r.check(not world.event_flag_active(MANSION_SWITCH_FLAG), + "the island did not clear EVENT_MANSION_SWITCH_ON.") + + +## `Mansion3Script_Switches`: the statue's row sits past every object's, asks, +## flips EVENT_MANSION_SWITCH_ON and sets the bit that redraws the doors. +func _check_a_mansion_switch() -> void: + var world: Gen2WorldAPI = _r.open_world(0, POKEMON_MANSION_3F, MANSION_3F_SWITCH) + if world == null: + return + world.dispatch_map_entry() + world.player_facing = Gen2WorldSprite.FACING_UP + var shut: bool = not world.can_walk_to(MANSION_3F_DOOR) + world.interact() + var asked: Dictionary = world.pending_script_input() + if not _r.check(StringName(asked.get("type", &"")) == &"choice" + and String(asked.get("text", "")).begins_with(MANSION_SWITCH_ASKS), + "the 3F switch asked %s." % [asked]): + return + world.choose_script_input(0) + world.run_event_queue(true) + world.dispatch_sight_events() + _r.check(world.event_flag_active(MANSION_SWITCH_FLAG), "the pressed switch left the event clear.") + _r.check(shut and world.can_walk_to(MANSION_3F_DOOR), + "the door at %s stood %s before and %s after." % [ + MANSION_3F_DOOR, "shut" if shut else "open", "open" if world.can_walk_to(MANSION_3F_DOOR) else "shut"]) + _r.note("gen1 walk POKEMON_MANSION_3F's switch opens its door") + + const POKEMON_TOWER_7F: int = 148 const TOWER_7F_BYTE: int = 0x40 const TOWER_7F_WARP_STATE: Dictionary = {&"red": 4, &"blue": 4, &"yellow": 11} diff --git a/tools/preview_world_story.gd b/tools/preview_world_story.gd index 21e07cb4..19d5cdc4 100644 --- a/tools/preview_world_story.gd +++ b/tools/preview_world_story.gd @@ -8951,7 +8951,8 @@ func _reachable_step( # cell it was asked to reach, which is what makes Ecruteak Gym's thirty # holes a maze instead of open floor. A warp_event on ordinary floor is # inert, as CheckWarpCollision has it, so it is not a wall. - if direct != warp_target and world.warp_pending(direct): + if direct != warp_target and (world.warp_pending(direct) \ + or not world.gen1_dungeon_hole_at(direct).is_empty()): return Vector2i(-1, -1) # A whirlpool traps rather than moves: .CheckTile answers # PLAYERMOVEMENT_FORCE_TURN for the cell the player stands on, so a plan that @@ -9015,7 +9016,7 @@ func _named_items(data: GameData, items: Dictionary) -> Dictionary: ## landfall names one land cell and the step onto it is .ExitWater. func _walk_to_story_cell( world: Gen2WorldAPI, target: Vector2i, water_only: bool = false, - dispatch_target_events: bool = true + dispatch_target_events: bool = true, avoid: Dictionary = {} ) -> Dictionary: if world == null or world.current_map == null: return {"ok": false, "reason": "missing world"} @@ -9024,7 +9025,7 @@ func _walk_to_story_cell( "ok": true, "events": _dispatch_after_step(world, target) if dispatch_target_events else [], } - var plan: Dictionary = _plan_walk(world, target, water_only) + var plan: Dictionary = _plan_walk(world, target, water_only, avoid) if not bool(plan["found"]): return { "ok": false, @@ -9055,7 +9056,9 @@ func _walk_to_story_cell( return {"ok": true, "steps": steps.size(), "events": events} -func _plan_walk(world: Gen2WorldAPI, target: Vector2i, water_only: bool = false) -> Dictionary: +func _plan_walk( + world: Gen2WorldAPI, target: Vector2i, water_only: bool = false, avoid: Dictionary = {} +) -> Dictionary: var frontier: Array[Vector2i] = [world.player_cell] var previous: Dictionary = {world.player_cell: {"cell": Vector2i(-1, -1), "direction": Vector2i.ZERO}} var directions: Array[Vector2i] = [Vector2i.UP, Vector2i.DOWN, Vector2i.LEFT, Vector2i.RIGHT] @@ -9072,7 +9075,7 @@ func _plan_walk(world: Gen2WorldAPI, target: Vector2i, water_only: bool = false) ) ## A spin tile is one edge to wherever its ride stops. next = arrows.get(next, next) - if next.x < 0 or previous.has(next): + if next.x < 0 or previous.has(next) or (next != target and avoid.has(next)): continue previous[next] = {"cell": cell, "direction": direction} frontier.append(next) @@ -9608,6 +9611,8 @@ const GEN1_BESIDE_CARD_KEY := Vector2i(20, 16) const GEN1_SILPH_5F_PAD_TO_3F := Vector2i(11, 5) const GEN1_SILPH_3F_DOOR_SIDE := Vector2i(18, 8) const GEN1_SILPH_3F_PAD_TO_7F := Vector2i(11, 11) +const GEN1_SILPH_LAPRAS_WORKER := Vector2i(1, 5) +const GEN1_DEX_LAPRAS: int = 131 const GEN1_SILPH_7F_PAD_TO_11F := Vector2i(5, 7) const GEN1_SILPH_11F_DOOR_SIDE := Vector2i(6, 14) const GEN1_SILPH_GIOVANNI := Vector2i(6, 9) @@ -9625,6 +9630,10 @@ const GEN1_SAFFRON_GYM_DOOR := Vector2i(34, 3) const GEN1_SAFFRON_GYM_PADS: Array = [ Vector2i(11, 15), Vector2i(15, 15), Vector2i(15, 5), Vector2i(1, 5), ] +## And from her room back to the door. +const GEN1_SAFFRON_GYM_PADS_OUT: Array = [ + Vector2i(11, 11), Vector2i(5, 5), Vector2i(5, 11), Vector2i(5, 15), Vector2i(19, 17), +] const GEN1_SABRINA := Vector2i(9, 8) const GEN1_FRESH_WATER: int = 0x3C const GEN1_CARD_KEY: int = 0x30 @@ -9635,6 +9644,80 @@ const GEN1_EVENT_BEAT_SILPH_CO_GIOVANNI: int = 1935 const GEN1_EVENT_GOT_TM46: int = 864 const GEN1_EVENT_BEAT_SABRINA: int = 865 const GEN1_BIT_MARSHBADGE: int = 5 +const GEN1_ROUTE_8_GATE: int = 79 +const GEN1_ROUTE_8_GATE_WEST := Vector2i(1, 10) +const GEN1_ROUTE_8_GATE_EAST := Vector2i(5, 3) +const GEN1_ROUTE_19: int = 30 +const GEN1_ROUTE_20: int = 31 +const GEN1_CINNABAR_ISLAND: int = 8 +const GEN1_SEAFOAM_ISLANDS_1F: int = 192 +const GEN1_SEAFOAM_ISLANDS_B1F: int = 159 +const GEN1_SEAFOAM_ISLANDS_B2F: int = 160 +const GEN1_SEAFOAM_ISLANDS_B3F: int = 161 +## Yellow's Route 19 keeps a beach house and two more rows of sand. +const GEN1_ROUTE_19_SHORE: Dictionary = { + &"red": Vector2i(8, 9), &"blue": Vector2i(8, 9), &"yellow": Vector2i(8, 11), +} +## A rock column at x = 62 parts Route 20's two seas below the islands' north +## field, so the sea from Fuchsia lands there and reaches the west door alone; +## the east door's own beach opens on the sea to Cinnabar. +const GEN1_ROUTE_20_NORTH_FIELD := Vector2i(61, 3) +const GEN1_ROUTE_20_SEAFOAM_WEST := Vector2i(48, 5) +const GEN1_SEAFOAM_1F_WEST_LADDER := Vector2i(7, 5) +const GEN1_SEAFOAM_B1F_WEST_LADDER := Vector2i(13, 7) +const GEN1_SEAFOAM_B2F_WEST_LADDER := Vector2i(5, 13) +## `TilePairCollisionsWater`'s CAVERN row keeps $05 banks off the pool, so the +## way across B3F is the one $15 bank on each side. +const GEN1_SEAFOAM_B3F_WEST_BANK := Vector2i(15, 7) +const GEN1_SEAFOAM_B3F_SHORE := Vector2i(23, 9) +const GEN1_SEAFOAM_B2F_EAST_LADDER := Vector2i(25, 14) +const GEN1_SEAFOAM_B1F_EAST_LADDER := Vector2i(25, 11) +const GEN1_SEAFOAM_1F_EAST_LADDER := Vector2i(23, 15) +const GEN1_SEAFOAM_1F_EAST_DOOR := Vector2i(26, 17) +const GEN1_ROUTE_20_EAST_BEACH := Vector2i(58, 11) +const GEN1_POKEMON_MANSION_1F: int = 165 +const GEN1_POKEMON_MANSION_2F: int = 214 +const GEN1_POKEMON_MANSION_3F: int = 215 +const GEN1_POKEMON_MANSION_B1F: int = 216 +const GEN1_CINNABAR_SHORE := Vector2i(19, 13) +const GEN1_MANSION_DOOR := Vector2i(6, 3) +const GEN1_MANSION_1F_STAIRS := Vector2i(5, 10) +const GEN1_MANSION_2F_STAIRS := Vector2i(6, 1) +## Each statue's own cell is one up from where it is pressed. +const GEN1_MANSION_3F_SWITCH := Vector2i(10, 6) +const GEN1_MANSION_3F_HOLE := Vector2i(16, 14) +const GEN1_MANSION_1F_B1F_STAIRS := Vector2i(21, 23) +const GEN1_MANSION_B1F_SOUTH_SWITCH := Vector2i(18, 26) +const GEN1_MANSION_B1F_NORTH_SWITCH := Vector2i(20, 4) +const GEN1_MANSION_SECRET_KEY := Vector2i(5, 13) +const GEN1_MANSION_B1F_STAIRS := Vector2i(23, 22) +const GEN1_MANSION_EAST_DOOR := Vector2i(26, 27) +const GEN1_SECRET_KEY: int = 0x2B +const GEN1_EVENT_MANSION_SWITCH_ON: int = 632 +const GEN1_NUGGET: int = 0x31 +const GEN1_CINNABAR_GYM: int = 166 +const GEN1_CINNABAR_GYM_DOOR := Vector2i(18, 3) +## Each machine's cell below it and `hGymGateAnswer`, the `wCurrentMenuItem` it +## wants: 0 is YES. +const GEN1_CINNABAR_QUIZ: Array = [ + [Vector2i(15, 8), 0], [Vector2i(10, 2), 1], [Vector2i(9, 8), 1], + [Vector2i(9, 14), 1], [Vector2i(1, 14), 0], [Vector2i(1, 8), 1], +] +const GEN1_EVENT_CINNABAR_GYM_GATE0_UNLOCKED: int = 680 +const GEN1_BLAINE := Vector2i(3, 3) +const GEN1_EVENT_BEAT_BLAINE: int = 665 +const GEN1_EVENT_GOT_TM38: int = 664 +const GEN1_BIT_VOLCANOBADGE: int = 6 +const GEN1_ROUTE_21: int = 32 +const GEN1_VIRIDIAN_GYM: int = 45 +const GEN1_CINNABAR_WEST_SHORE := Vector2i(4, 4) +const GEN1_PALLET_SHORE := Vector2i(8, 15) +const GEN1_TM11: int = 0xD3 +const GEN1_VIRIDIAN_GYM_DOOR := Vector2i(32, 7) +const GEN1_VIRIDIAN_GIOVANNI := Vector2i(2, 1) +const GEN1_EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI: int = 81 +const GEN1_EVENT_GOT_TM27: int = 80 +const GEN1_BIT_EARTHBADGE: int = 7 const GEN1_BEDROOM_STAIRS := Vector2i(7, 1) const GEN1_HOUSE_DOOR := Vector2i(2, 7) ## `PalletTownDefaultScript` stops the player at `wYCoord == 1`, and Yellow's at 0. @@ -9717,7 +9800,8 @@ func _gen1_story_path(data: GameData) -> Dictionary: _gen1_vermilion_gym_leg, _gen1_rock_tunnel_leg, _gen1_celadon_gym_leg, _gen1_rocket_hideout_leg, _gen1_pokemon_tower_leg, _gen1_fuchsia_gym_leg, _gen1_safari_zone_leg, _gen1_saffron_drink_leg, _gen1_silph_co_leg, - _gen1_saffron_gym_leg, + _gen1_saffron_gym_leg, _gen1_cinnabar_leg, _gen1_mansion_leg, _gen1_cinnabar_gym_leg, + _gen1_viridian_gym_leg, ] for leg: Callable in legs: var walked: Dictionary = leg.call(world, save, random, data, path) @@ -9738,7 +9822,7 @@ func _gen1_story_path(data: GameData) -> Dictionary: ## a warp takes it, as `CheckWarpsNoCollision` does under a scripted walk. func _gen1_settle( world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, - results: Array = [] + results: Array = [], answers: Array[int] = [] ) -> Dictionary: var statuses: Array = [] var battles: Array = [] @@ -9746,7 +9830,8 @@ func _gen1_settle( var warps: int = 0 for pass_index: int in GEN1_SETTLE_PASSES: if not results.is_empty(): - var run: Dictionary = _drain_story(world, results, save, random, data) + var run: Dictionary = _drain_story(world, results, save, random, data, false, answers) + answers = [] texts.append_array(run.get("texts", [])) statuses.append_array(run.get("statuses", [])) battles.append_array(run.get("battles", [])) @@ -9761,9 +9846,14 @@ func _gen1_settle( world.advance_script_wait_frame() world.advance_player_step_pass() world.advance_scripted_steps_pass() - if stepping and not world.player_step_in_progress() and world.warp_pending(): - if bool(world.try_warp().get("ok", false)): - warps += 1 + if stepping and not world.player_step_in_progress(): + if world.warp_pending(): + if bool(world.try_warp().get("ok", false)): + warps += 1 + ## `RunMapScript` sets the dungeon warp bit, read behind `CheckWarpsNoCollision`. + elif not world.gen1_dungeon_hole_at(world.player_cell).is_empty(): + if bool(world.gen1_dungeon_fall().get("ok", false)): + warps += 1 results = world.dispatch_sight_events() if results.is_empty() and _gen1_settled(world) \ and world.gen1_map_script_state() == script_state: @@ -9806,9 +9896,17 @@ func _gen1_walk( ) -> Dictionary: var runs: Array = [] var from: Vector2i = world.map_id() + ## A cell whose script only moved the player off it is planned around next + ## time, which is how Cinnabar's locked gym door is passed by the row below + ## it; a scene that fought first, like Mt. Moon's Rockets, is over. + var avoid: Dictionary = {} for _attempt: int in WALK_RESOLVE_ATTEMPTS: - var walked: Dictionary = _walk_to_story_cell(world, cell) + var walked: Dictionary = _walk_to_story_cell(world, cell, _gen1_surfing(world), true, avoid) + var interrupted: Vector2i = world.player_cell var settled: Dictionary = _gen1_settle(world, save, random, data, walked.get("events", [])) + if world.player_cell != interrupted and world.map_id() == from \ + and settled.get("battles", []).is_empty(): + avoid[interrupted] = true if not settled.get("texts", []).is_empty() or not settled.get("battles", []).is_empty(): runs.append({"cell": _cell_value(world), "texts": settled.get("texts", []), "battles": settled.get("battles", [])}) @@ -9865,7 +9963,7 @@ func _gen1_cross( ) -> Dictionary: var runs: Array = [] for _attempt: int in WALK_RESOLVE_ATTEMPTS: - var walked: Dictionary = _walk_to_connection(world, direction, 0, map) + var walked: Dictionary = _walk_to_connection(world, direction, 0, map, _gen1_surfing(world)) var settled: Dictionary = _gen1_settle(world, save, random, data, walked.get("events", [])) if not settled.get("texts", []).is_empty() or not settled.get("battles", []).is_empty(): runs.append({"cell": _cell_value(world), "texts": settled.get("texts", []), @@ -9884,13 +9982,13 @@ func _gen1_cross( func _gen1_talk( world: Gen2WorldAPI, cell: Vector2i, facing: int, save: Gen2SaveData, - random: RandomNumberGenerator, data: GameData + random: RandomNumberGenerator, data: GameData, answers: Array[int] = [] ) -> Dictionary: var walked: Dictionary = _gen1_walk(world, cell, save, random, data) if not bool(walked.get("ok", false)): return walked world.player_facing = facing - var settled: Dictionary = _gen1_settle(world, save, random, data, world.interact()) + var settled: Dictionary = _gen1_settle(world, save, random, data, world.interact(), answers) settled["runs"] = walked["runs"] return settled @@ -10459,6 +10557,32 @@ func _gen1_cut( return {"ok": bool(applied.get("ok", false)), "reason": applied.get("reason", ""), "runs": walked["runs"]} +## A surfing walk keeps to the water until the cell it was asked for, which is +## where it lands, as `.stopSurfing` has the step onto a passable tile end it. +func _gen1_surfing(world: Gen2WorldAPI) -> bool: + return world.movement_mode == Gen2WorldAPI.MOVEMENT_SURF + + +## `ItemUseSurfboard` from [param cell] facing [param facing]'s water. +func _gen1_surf( + world: Gen2WorldAPI, cell: Vector2i, facing: int, save: Gen2SaveData, + random: RandomNumberGenerator, data: GameData +) -> Dictionary: + var walked: Dictionary = _gen1_walk(world, cell, save, random, data) + if not bool(walked.get("ok", false)): + return walked + world.player_facing = facing + var request: Dictionary = world.surf_request() + if not bool(request.get("ok", false)): + return {"ok": false, "reason": "surf refused: %s" % request.get("reason", ""), "runs": walked["runs"]} + var applied: Dictionary = world.complete_surf() + if not bool(applied.get("ok", false)): + return {"ok": false, "reason": "surf failed: %s" % applied.get("reason", ""), "runs": walked["runs"]} + var settled: Dictionary = _gen1_settle(world, save, random, data) + settled["runs"] = walked["runs"] + return settled + + ## Vermilion back up the Underground Path, Route 9 behind its tree, Rock ## Tunnel's two ladders and out to Lavender Town. func _gen1_rock_tunnel_leg( @@ -10988,6 +11112,18 @@ func _gen1_silph_co_leg( return stepped stepped = _gen1_warp_legs(path, world, save, random, data, [ [GEN1_SILPH_CO_7F, "silph_3f_pad_to_7f", GEN1_SILPH_3F_PAD_TO_7F], + ]) + if not bool(stepped["ok"]): + return stepped + ## `SilphCo7FSilphWorkerM1Text`: the LAPRAS that will carry the walk to Cinnabar. + var lapras: Dictionary = _gen1_talk_to(world, GEN1_SILPH_LAPRAS_WORKER, save, random, data) + _mirror_party(world, save) + stepped = _gen1_step(path, "silph_7f_lapras", world, lapras, {"party": _party_species(save)}) + if not bool(stepped["ok"]): + return stepped + if not _party_species(save).any(func(mon: Dictionary) -> bool: return int(mon["species"]) == GEN1_DEX_LAPRAS): + return {"ok": false, "path": path, "reason": "silph_7f_lapras: the party is %s" % [_party_species(save)]} + stepped = _gen1_warp_legs(path, world, save, random, data, [ [GEN1_SILPH_CO_11F, "silph_7f_pad_to_11f", GEN1_SILPH_7F_PAD_TO_11F], ]) if not bool(stepped["ok"]): @@ -11043,6 +11179,263 @@ func _gen1_card_key( return {"ok": true} +## SURF on the LAPRAS, Fuchsia by the gates again, Route 19's sea, Route 20 through +## the Seafoam Islands (B3F's pool is the way between its halves) and Cinnabar. +func _gen1_cinnabar_leg( + world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, + path: Array +) -> Dictionary: + var taught: Dictionary = _teach_tm_hm(world, save, GEN1_HM03) + _mirror_party(world, save) + var stepped: Dictionary = _gen1_step(path, "saffron_teach_surf", world, taught, { + "moves": _party_moves(save)}) + if not bool(stepped["ok"]): + return stepped + var legs: Array = [] + for pad: int in GEN1_SAFFRON_GYM_PADS_OUT.size(): + legs.append([GEN1_SAFFRON_GYM, "saffron_gym_pad_out_%d" % (pad + 1), GEN1_SAFFRON_GYM_PADS_OUT[pad]]) + stepped = _gen1_warp_legs(path, world, save, random, data, legs + [ + [GEN1_SAFFRON_CITY, "saffron_gym_exit"], + ["east", GEN1_ROUTE_8, "saffron_to_route_8"], + [GEN1_ROUTE_8_GATE, "route_8_gate", GEN1_ROUTE_8_GATE_WEST], + [GEN1_ROUTE_8, "route_8_gate_east", GEN1_ROUTE_8_GATE_EAST], + ["east", GEN1_LAVENDER_TOWN, "route_8_to_lavender_again"], + ["south", GEN1_ROUTE_12, "lavender_to_route_12_again"], + [GEN1_ROUTE_12_GATE_1F, "route_12_gate_again", GEN1_ROUTE_12_GATE_DOOR], + [GEN1_ROUTE_12, "route_12_gate_south_again", GEN1_ROUTE_12_GATE_SOUTH], + ["south", GEN1_ROUTE_13, "route_12_to_route_13_again"], + ["west", GEN1_ROUTE_14, "route_13_to_route_14_again", GEN1_ROUTE_13_WEST_EDGE], + ["west", GEN1_ROUTE_15, "route_14_to_route_15_again"], + [GEN1_ROUTE_15_GATE_1F, "route_15_gate_again", GEN1_ROUTE_15_GATE_DOOR], + [GEN1_ROUTE_15, "route_15_gate_west_again", GEN1_ROUTE_15_GATE_WEST], + ["west", GEN1_FUCHSIA_CITY, "route_15_to_fuchsia_again"], + ["south", GEN1_ROUTE_19, "fuchsia_to_route_19"], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_step(path, "route_19_surf", world, _gen1_surf( + world, GEN1_ROUTE_19_SHORE[data.id], Gen2WorldSprite.FACING_DOWN, save, random, data + )) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_crossings(path, world, save, random, data, [ + ["west", GEN1_ROUTE_20, "route_19_to_route_20"], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_step(path, "route_20_north_field", world, _gen1_walk( + world, GEN1_ROUTE_20_NORTH_FIELD, save, random, data + )) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_SEAFOAM_ISLANDS_1F, "seafoam_west_door", GEN1_ROUTE_20_SEAFOAM_WEST], + [GEN1_SEAFOAM_ISLANDS_B1F, "seafoam_1f_to_b1f", GEN1_SEAFOAM_1F_WEST_LADDER], + [GEN1_SEAFOAM_ISLANDS_B2F, "seafoam_b1f_to_b2f", GEN1_SEAFOAM_B1F_WEST_LADDER], + [GEN1_SEAFOAM_ISLANDS_B3F, "seafoam_b2f_to_b3f", GEN1_SEAFOAM_B2F_WEST_LADDER], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_step(path, "seafoam_b3f_surf", world, _gen1_surf( + world, GEN1_SEAFOAM_B3F_WEST_BANK, Gen2WorldSprite.FACING_DOWN, save, random, data + )) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_step(path, "seafoam_b3f_east_bank", world, _gen1_walk( + world, GEN1_SEAFOAM_B3F_SHORE, save, random, data + )) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_SEAFOAM_ISLANDS_B2F, "seafoam_b3f_to_b2f", GEN1_SEAFOAM_B2F_EAST_LADDER], + [GEN1_SEAFOAM_ISLANDS_B1F, "seafoam_b2f_to_b1f", GEN1_SEAFOAM_B1F_EAST_LADDER], + [GEN1_SEAFOAM_ISLANDS_1F, "seafoam_b1f_to_1f", GEN1_SEAFOAM_1F_EAST_LADDER], + [GEN1_ROUTE_20, "seafoam_east_door", GEN1_SEAFOAM_1F_EAST_DOOR], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_step(path, "route_20_surf_again", world, _gen1_surf( + world, GEN1_ROUTE_20_EAST_BEACH, Gen2WorldSprite.FACING_DOWN, save, random, data + )) + if not bool(stepped["ok"]): + return stepped + return _gen1_warp_legs(path, world, save, random, data, [ + ["west", GEN1_CINNABAR_ISLAND, "route_20_to_cinnabar"], + ]) + + +## `MansionScript_Switches`: the statue in front of [param cell] pressed, and +## [param on] is where EVENT_MANSION_SWITCH_ON has to stand behind it. +func _gen1_mansion_switch( + path: Array, step: String, world: Gen2WorldAPI, cell: Vector2i, on: bool, + save: Gen2SaveData, random: RandomNumberGenerator, data: GameData +) -> Dictionary: + var pressed: Dictionary = _gen1_talk(world, cell, Gen2WorldSprite.FACING_UP, save, random, data) + var stepped: Dictionary = _gen1_step(path, step, world, pressed) + if not bool(stepped["ok"]): + return stepped + if world.event_flag_active(GEN1_EVENT_MANSION_SWITCH_ON) != on: + return {"ok": false, "path": path, "reason": "%s: the switch stands %s" % [ + step, "on" if world.event_flag_active(GEN1_EVENT_MANSION_SWITCH_ON) else "off"]} + return {"ok": true} + + +## The POKéMON MANSION to the SECRET KEY: 3F's switch and its hole down to 1F's +## east half, B1F's two statues turned to open the key's room and then the way +## back to its stairs, and out by the east doors. +func _gen1_mansion_leg( + world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, + path: Array +) -> Dictionary: + var stepped: Dictionary = _gen1_step(path, "cinnabar_shore", world, _gen1_walk( + world, GEN1_CINNABAR_SHORE, save, random, data + )) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_POKEMON_MANSION_1F, "mansion_entry", GEN1_MANSION_DOOR], + [GEN1_POKEMON_MANSION_2F, "mansion_1f_to_2f", GEN1_MANSION_1F_STAIRS], + [GEN1_POKEMON_MANSION_3F, "mansion_2f_to_3f", GEN1_MANSION_2F_STAIRS], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_mansion_switch(path, "mansion_3f_switch_on", world, GEN1_MANSION_3F_SWITCH, true, save, random, data) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_POKEMON_MANSION_1F, "mansion_3f_hole", GEN1_MANSION_3F_HOLE], + [GEN1_POKEMON_MANSION_B1F, "mansion_1f_to_b1f", GEN1_MANSION_1F_B1F_STAIRS], + ]) + if not bool(stepped["ok"]): + return stepped + for turn: Array in [ + ["mansion_b1f_south_switch_off", GEN1_MANSION_B1F_SOUTH_SWITCH, false], + ["mansion_b1f_north_switch_on", GEN1_MANSION_B1F_NORTH_SWITCH, true], + ]: + stepped = _gen1_mansion_switch(path, String(turn[0]), world, turn[1], bool(turn[2]), save, random, data) + if not bool(stepped["ok"]): + return stepped + var key: Dictionary = _gen1_talk_to(world, GEN1_MANSION_SECRET_KEY, save, random, data) + stepped = _gen1_step(path, "mansion_b1f_secret_key", world, key, { + "items": _named_items(data, world.state.items())}) + if not bool(stepped["ok"]): + return stepped + if int(world.state.items().get(GEN1_SECRET_KEY, 0)) != 1: + return {"ok": false, "path": path, "reason": "mansion_b1f_secret_key: the bag holds %s" % [ + _named_items(data, world.state.items())]} + for turn: Array in [ + ["mansion_b1f_north_switch_off", GEN1_MANSION_B1F_NORTH_SWITCH, false], + ["mansion_b1f_south_switch_on", GEN1_MANSION_B1F_SOUTH_SWITCH, true], + ]: + stepped = _gen1_mansion_switch(path, String(turn[0]), world, turn[1], bool(turn[2]), save, random, data) + if not bool(stepped["ok"]): + return stepped + return _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_POKEMON_MANSION_1F, "mansion_b1f_to_1f", GEN1_MANSION_B1F_STAIRS], + [GEN1_CINNABAR_ISLAND, "mansion_exit", GEN1_MANSION_EAST_DOOR], + ]) + + +## Cinnabar's gym: the six quiz machines answered as `CinnabarGymQuiz`'s own +## nibble has them, each gate opening on the way round to Blaine. +func _gen1_cinnabar_gym_leg( + world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, + path: Array +) -> Dictionary: + ## Twenty items fill a Generation 1 bag, and Blaine's TM38 is the twenty-first. + var tossed: Dictionary = Gen2WorldBagHost.toss(world, save, GEN1_NUGGET, 1, false) + var stepped: Dictionary = _gen1_step(path, "cinnabar_toss_nugget", world, tossed, { + "items": _named_items(data, world.state.items())}) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_CINNABAR_GYM, "cinnabar_gym_entry", GEN1_CINNABAR_GYM_DOOR], + ]) + if not bool(stepped["ok"]): + return stepped + for machine: int in GEN1_CINNABAR_QUIZ.size(): + var row: Array = GEN1_CINNABAR_QUIZ[machine] + var answer: Array[int] = [int(row[1])] + var quiz: Dictionary = _gen1_talk(world, row[0], Gen2WorldSprite.FACING_UP, save, random, data, answer) + stepped = _gen1_step(path, "cinnabar_gym_quiz_%d" % (machine + 1), world, quiz, { + "party": _party_species(save)}) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_flag_leg(path, "cinnabar_gym_quiz_%d" % (machine + 1), world, + GEN1_EVENT_CINNABAR_GYM_GATE0_UNLOCKED + machine + 1, "EVENT_CINNABAR_GYM_GATE%d_UNLOCKED" % (machine + 1)) + if not bool(stepped["ok"]): + return stepped + var blaine: Dictionary = _gen1_talk_to(world, GEN1_BLAINE, save, random, data) + stepped = _gen1_step(path, "cinnabar_gym_blaine", world, blaine, { + "party": _party_species(save), "items": _named_items(data, world.state.items()), + "badges": world.state.badge_count(), + }) + if not bool(stepped["ok"]): + return stepped + for flag: Array in [ + [GEN1_EVENT_BEAT_BLAINE, "EVENT_BEAT_BLAINE"], [GEN1_EVENT_GOT_TM38, "EVENT_GOT_TM38"], + ]: + stepped = _gen1_flag_leg(path, "cinnabar_gym_blaine", world, int(flag[0]), String(flag[1])) + if not bool(stepped["ok"]): + return stepped + if not world.state.is_engine_flag_active(Gen2WorldState.gen1_badge_flag(GEN1_BIT_VOLCANOBADGE)): + return {"ok": false, "path": path, "reason": "cinnabar_gym_blaine: VOLCANOBADGE is clear"} + return _gen1_warp_legs(path, world, save, random, data, [[GEN1_CINNABAR_ISLAND, "cinnabar_gym_exit"]]) + + +## Route 21 north to Pallet, Route 1 to Viridian and its gym: Giovanni again, +## the EARTHBADGE and TM27. +func _gen1_viridian_gym_leg( + world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, + path: Array +) -> Dictionary: + var stepped: Dictionary = _gen1_step(path, "cinnabar_surf_north", world, _gen1_surf( + world, GEN1_CINNABAR_WEST_SHORE, Gen2WorldSprite.FACING_LEFT, save, random, data + )) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_crossings(path, world, save, random, data, [ + ["north", GEN1_ROUTE_21, "cinnabar_to_route_21"], + ["north", GEN1_PALLET_TOWN, "route_21_to_pallet"], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_step(path, "pallet_shore", world, _gen1_walk( + world, GEN1_PALLET_SHORE, save, random, data + )) + if not bool(stepped["ok"]): + return stepped + var tossed: Dictionary = Gen2WorldBagHost.toss(world, save, GEN1_TM11, 1, false) + stepped = _gen1_step(path, "pallet_toss_tm11", world, tossed, { + "items": _named_items(data, world.state.items())}) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + ["north", GEN1_ROUTE_1, "pallet_to_route_1_again"], + ["north", GEN1_VIRIDIAN_CITY, "route_1_to_viridian_again"], + [GEN1_VIRIDIAN_GYM, "viridian_gym_entry", GEN1_VIRIDIAN_GYM_DOOR], + ]) + if not bool(stepped["ok"]): + return stepped + var giovanni: Dictionary = _gen1_talk_to(world, GEN1_VIRIDIAN_GIOVANNI, save, random, data) + stepped = _gen1_step(path, "viridian_gym_giovanni", world, giovanni, { + "party": _party_species(save), "items": _named_items(data, world.state.items()), + "badges": world.state.badge_count(), + }) + if not bool(stepped["ok"]): + return stepped + for flag: Array in [ + [GEN1_EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI, "EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI"], + [GEN1_EVENT_GOT_TM27, "EVENT_GOT_TM27"], + ]: + stepped = _gen1_flag_leg(path, "viridian_gym_giovanni", world, int(flag[0]), String(flag[1])) + if not bool(stepped["ok"]): + return stepped + if not world.state.is_engine_flag_active(Gen2WorldState.gen1_badge_flag(GEN1_BIT_EARTHBADGE)): + return {"ok": false, "path": path, "reason": "viridian_gym_giovanni: EARTHBADGE is clear"} + return _gen1_warp_legs(path, world, save, random, data, [[GEN1_VIRIDIAN_CITY, "viridian_gym_exit"]]) + + ## The gym's pads to Sabrina, and the MARSHBADGE. func _gen1_saffron_gym_leg( world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, From ab97fbc0e4b745e48486396becc5497891c96012 Mon Sep 17 00:00:00 2001 From: Decrypt <90518536+Decryptu@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:17:52 +0200 Subject: [PATCH 8/8] Walk Red, Blue and Yellow into the Hall of Fame and offer Play on all three The story walk teaches STRENGTH to the LAPRAS, crosses Route 22's gate and Route 23's guards and river, solves Victory Road's three boulder switches, beats the Elite Four, Lance and the Champion, and is inducted into the Hall of Fame: 360 steps on Red and Blue, 361 on Yellow. RomRegistry marks all three playable, which is what the shelf's Play reads. EndTrainerBattle's ResetButtonPressedAndMapScript zeroes wCurMapScript, which the silent call dropped: an end-battle state that calls it and prints on, as every Elite Four room's does, stayed on itself and printed the after-battle line for ever. The call writes the zero now. Cache format 132. DisplayTextID by id is hSpriteIndex too, so a map script opening a trainer's row hands TalkToTrainer the object of that index; Lance's own coordinate trigger had asked for a wild Pokemon of species 0. HallOfFamePC is a runtime request on Generation 1 that nothing answered, and jp Init behind the Hall's save is another: the world screen opens the induction and the credits for the first and restarts for the second, the node commits ENGINE_HALL_OF_FAME in front of the request, and the story walk stops at the reset. A real playthrough had ended on a prompt to press A. A Generation 1 boulder moves alone: TryPushingBoulder is MoveSprite on the boulder, armed by a first bump, so the walk steps after it between pushes. gen1_walk pins the Elite room's return to its default state, Lance's trigger, and the induction's request, flag, save and reset. --- README.md | 2 +- autoload/game_runtime.gd | 3 - game/battle/battle_anim_bg_effects.gd | 3 - game/battle/party.gd | 3 - game/gen1/gen1_world_importer.gd | 12 +- game/import/rom_cache.gd | 2 +- game/input/input_actions.gd | 3 - game/launcher/launcher_ui.gd | 3 - game/mods/mod_catalogue.gd | 3 - game/options/options.gd | 3 - game/render/gender_screen_page.gd | 3 - game/render/second_screen_host.gd | 2 - game/render/town_map_page.gd | 2 - game/rom/rom_registry.gd | 9 +- game/rom/rom_verifier.gd | 2 - game/save/save_screen.gd | 2 - game/save/sram_adapter.gd | 2 - game/world/game_freak_presents.gd | 2 - game/world/intro_movie.gd | 3 - game/world/move_deleter.gd | 2 - game/world/pokegear_screen.gd | 2 - game/world/prof_oaks_pc.gd | 2 - game/world/trainer_card.gd | 2 - game/world/world_animation.gd | 3 - game/world/world_api.gd | 34 ++- game/world/world_bag_host.gd | 2 - game/world/world_effects.gd | 2 - game/world/world_radio.gd | 2 - game/world/world_screen.gd | 21 ++ game/world/world_tileset.gd | 2 - tests/unit/test_launcher_ui.gd | 31 ++- tests/unit/test_source_budget.gd | 2 +- tools/checks/gen1_maps.gd | 51 +++-- tools/checks/gen1_walk.gd | 93 +++++++++ tools/preview_world_story.gd | 290 +++++++++++++++++++++++++- 35 files changed, 487 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index a23de025..dda6b4ee 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ The rest are previews and dumps, each driving a real screen or table: ```bash # the full walked route: Johto, the Hall of Fame, every Kanto gym, and Red godot --headless --path . -s res://tools/preview_world_story.gd -- crystal 24 7 2 2 1 none home story -# Red, Blue or Yellow from the bedroom to the EARTHBADGE +# Red, Blue or Yellow from the bedroom to the Hall of Fame godot --headless --path . -s res://tools/preview_world_story.gd -- red 0 38 3 6 1 none home story ``` diff --git a/autoload/game_runtime.gd b/autoload/game_runtime.gd index 070647b6..fe8d2db7 100644 --- a/autoload/game_runtime.gd +++ b/autoload/game_runtime.gd @@ -2,7 +2,6 @@ class_name Gen2GameRuntime extends Node ## Runtime selection shared by the launcher and screens opened from it. -## ## Cartridge bytes and decoded data stay owned by their existing layers. The ## selected save is the exception: it is mutable, and every screen has to see the ## same instance. Re-reading the slot per call handed a battle result, a party @@ -14,7 +13,6 @@ var selected_game_id: StringName = &"" var selected_save_slot: int = -1 ## The new game the intro is running for, before it exists on disk. -## ## `NewGame` reaches `InitializeWorld` only after `PlayerProfileSetup` and ## `OakSpeech` have both returned, so there is nothing to write until the intro ## finishes. The launcher stages the slot and the slot's own label here, the @@ -99,7 +97,6 @@ var _second_screen_host: Gen2SecondScreenHost = null ## Loads installed mods before any screen exists. -## ## A mod registers what it provides and returns, so this must happen before the ## first screen asks the host: a renderer registered after the overworld was ## built would not be offered until the next map. It also means a broken mod is diff --git a/game/battle/battle_anim_bg_effects.gd b/game/battle/battle_anim_bg_effects.gd index 658ea4e7..776c4b89 100644 --- a/game/battle/battle_anim_bg_effects.gd +++ b/game/battle/battle_anim_bg_effects.gd @@ -4,7 +4,6 @@ extends RefCounted ## The `BATTLE_BG_EFFECT_*` jumptable and the routines it dispatches ## (engine/battle_anims/bg_effects.asm): the screen shakes, the scanline ## deformations, the palette fades and the tilemap edits. Five run at once. -## ## An effect id is profile-local and is never normalised: pokegold ships no ## `BATTLE_BG_EFFECT_BODY_SLAM`, so every id from $25 on names a different effect ## in the two games; both jumptables are kept whole and dispatch is by name. The @@ -444,7 +443,6 @@ static func _hues( ## `BGEffect_RapidCyclePals`, the Color branch: one side of the field at a time, ## and the enemy's own states are the two above the player's. -## ## Nothing here ends the walk. The list's own `$ff` only stalls it; the effect is ## retired by the script's `anim_incbgeffect`, which is what steps it onto the ## state that puts the palette back. @@ -1336,7 +1334,6 @@ static func _shake_amount( ## `BattleBGEffect_Rollout`: the screen shaken vertically, with the first ## animation object moved the opposite way so the ball it draws stays put. -## ## The source waits a frame here rather than at the bottom of `.playframe`; ## in a player stepped once per frame that is the same one frame either way. static func _rollout( diff --git a/game/battle/party.gd b/game/battle/party.gd index 53779a68..6be8e811 100644 --- a/game/battle/party.gd +++ b/game/battle/party.gd @@ -2,11 +2,9 @@ class_name Gen2Party extends RefCounted ## The Pokémon one side of a battle has, and which of them is out. -## ## An ordered list and a cursor into it, not a set: the order is what a switch ## menu shows and what a replacement is chosen from, and the first entry leads. A ## wild battle is a party of one, so it needs no special case. -## ## Fainted Pokémon stay in the list, still counting against the six. A battle is ## lost when all of them are down, not when the list runs out. @@ -88,7 +86,6 @@ func first_healthy() -> int: ## Puts [param index] out, and answers whether it went. -## ## The Pokémon leaving keeps its health and PP and loses its stat stages and ## everything [Gen2Substatus] holds. A stage is a lens on a stat, and neither it ## nor confusion, a charge or a recharge survives the walk back to the ball. diff --git a/game/gen1/gen1_world_importer.gd b/game/gen1/gen1_world_importer.gd index 595314e1..88f6c09a 100644 --- a/game/gen1/gen1_world_importer.gd +++ b/game/gen1/gen1_world_importer.gd @@ -3628,6 +3628,16 @@ static func _script_special_body(ctx: Dictionary, pc: int, state: Dictionary = { return [{"op": "badge_guards", "rows": rows, "past_y": compares[0], "past_x": compares[1]}] +## `ResetButtonPressedAndMapScript` behind `EndTrainerBattle` zeroes +## `wCurMapScript`, so an end-battle state that calls it and prints on, as the +## Elite Four's do, ends on the default state rather than itself. +static func _script_silent_call(routine: String, state: Dictionary, out: Array, next: int) -> int: + if routine == "end_trainer_battle": + state["a"] = 0 + _script_map_script_mirror(state, out) + return next + + ## The routines a row may call. No node carries a sound, so a cry and the wait ## behind it spend nothing and the walk carries on past them. static func _script_call( @@ -3640,7 +3650,7 @@ static func _script_call( _script_untested(state) var routine: String = _script_routine(layout, target) if routine in Gen1Layout.SCRIPT_SILENT_CALLS: - return next + return _script_silent_call(routine, state, out, next) var shaped: int = _script_shaped_routine(ctx, target, state, out) if shaped == SCRIPT_NOT_SHAPED: shaped = _script_card_key_call(ctx, target, state, out) diff --git a/game/import/rom_cache.gd b/game/import/rom_cache.gd index c190bd34..c807fc83 100644 --- a/game/import/rom_cache.gd +++ b/game/import/rom_cache.gd @@ -77,7 +77,7 @@ const BYTES_KEY: String = "bytes" ## a dump the owner still has, so re-importing costs a few seconds and a ## migration would have to carry every past shape forever. Nothing but the cache ## is thrown away, since saves live under their own root. -const FORMAT_VERSION: int = 131 +const FORMAT_VERSION: int = 132 ## What [method state] answers. A stale cache is told from a missing one because ## they need different things said to whoever is looking at it: one is a diff --git a/game/input/input_actions.gd b/game/input/input_actions.gd index 92be48b6..a41387ff 100644 --- a/game/input/input_actions.gd +++ b/game/input/input_actions.gd @@ -29,7 +29,6 @@ const DEADZONE: float = 0.5 const ALL_DEVICES: int = -1 ## The pad button Godot's own UI actions are missing. -## ## `ui_accept` ships as Enter, Keypad Enter and Space, and `ui_cancel` as Escape ## alone, so on a machine with no keyboard every focus ring in the launcher can ## be moved and nothing under it can be chosen. The page pair and the menu key @@ -200,7 +199,6 @@ static func defaults() -> Dictionary: ## Replaces every `gen2_*` action in the [InputMap] with these bindings. -## ## Rebuilt rather than merged: a rebind that only added would leave the previous ## key working, and the player would have two keys where the screen says one. static func install(scheme: Dictionary) -> void: @@ -387,7 +385,6 @@ static func action_badge( ## Reads a scheme back out of the options file. -## ## Clamped rather than refused, the way the rest of [Gen2Options] is: one ## unreadable binding should cost that binding, and a button left with none at ## all falls back to its default rather than becoming unpressable. diff --git a/game/launcher/launcher_ui.gd b/game/launcher/launcher_ui.gd index 7effba3d..3f762716 100644 --- a/game/launcher/launcher_ui.gd +++ b/game/launcher/launcher_ui.gd @@ -77,7 +77,6 @@ static func apply_display_density(window: Window, on: bool) -> void: ## Keeps [param root]'s window drawn in launcher units for as long as [param root] ## is in the tree, and puts it back when it leaves. -## ## Every screen written in launcher units needs this and only those do, so it is ## a guard a screen attaches rather than something a shell happens to own: the ## save editor is drawn in the same units and has no shell, and without the @@ -201,7 +200,6 @@ static func muted(theme: Gen2LauncherTheme, text: String) -> Label: ## A muted word that does not wrap, for a line of short facts. -## ## [method muted] wraps, and a wrapping label reports almost no minimum width, so ## one dropped into a flow row or an expanding box is squeezed to a character a ## line. A fact is short enough to keep whole and long enough to be worth the @@ -242,7 +240,6 @@ static func row(separation: int = GAP_MD) -> HBoxContainer: ## Empties a container that is about to be rebuilt. -## ## Every launcher pane is rebuilt from a button inside it, so the node emitting ## `pressed` is one of the children being removed and `free()` there destroys an ## object the signal is still walking. Detaching first takes the child out of the diff --git a/game/mods/mod_catalogue.gd b/game/mods/mod_catalogue.gd index d6f349f7..69bd93b4 100644 --- a/game/mods/mod_catalogue.gd +++ b/game/mods/mod_catalogue.gd @@ -16,7 +16,6 @@ const SOURCE_FILE_LABEL: String = "Installed from a file" ## The groups to draw, in the order to draw them: each followed source that has ## anything in it, in the order the player added them, then the file group. -## ## [param sources] is [method PokeModIndex.followed]'s rows, [param listings] is ## feed to that feed's entries, [param manifests] is ## [method Gen2ModHost.manifests] and [param present] is @@ -66,7 +65,6 @@ static func groups( ## One row of the list. [param entry] is the source's listing for it, empty for a ## mod no source lists; [param manifest] is the installed copy, null for one that ## is only listed. -## ## `version` is what the row shows: the installed version when there is one, ## because that is the copy the player has, and the listed version otherwise. ## [param present] is whether the mods root holds a directory for it, which is @@ -106,7 +104,6 @@ static func _row( ## What the row's own action does, which is the one place the Cydia rule lives: ## a listed mod is downloaded, updated or reinstalled, and an installed one is ## removed. Removing a listed mod leaves it listed. -## ## `replace` is a copy on disk this host would not load, which is what a ## contract bump leaves behind: the installer refuses a plain download over it, ## so the press has to say it is replacing what is there. diff --git a/game/options/options.gd b/game/options/options.gd index 485cc23a..b63cd4c4 100644 --- a/game/options/options.gd +++ b/game/options/options.gd @@ -2,11 +2,9 @@ class_name Gen2Options extends RefCounted ## Player options, in two independent blocks. -## ## The cartridge block is the real `wOptions` .. `wOptionsEnd` bytes, so the ## in-game OPTION menu and this launcher screen can never disagree about what a ## setting means. The app block is everything the hardware had no concept of. -## ## `data/default_options.asm` is byte identical between the two pins, so nothing ## here is profile split. @@ -167,7 +165,6 @@ func text_reveal_speed() -> float: ## Hardware frames per real second, as a multiple of the cartridge's own rate. -## ## Applied by [Gen2WorldAnimation.FrameClock] and nowhere else, which is what ## keeps it off the sound driver: [Gen2AudioPlayer] fills its generator from the ## output's own demand, so music, effects and cries run at the cartridge's tempo diff --git a/game/render/gender_screen_page.gd b/game/render/gender_screen_page.gd index 35fda354..bc143c65 100644 --- a/game/render/gender_screen_page.gd +++ b/game/render/gender_screen_page.gd @@ -3,11 +3,9 @@ extends RefCounted ## `engine/menus/init_gender.asm`'s screen on the hardware tile grid: the ## question in the standard text box and the two-option menu over it. -## ## Positions are the source's own. The menu is [Gen2MenuPage]'s, drawn from ## `.MenuHeader`'s `menu_coords 6, 4, 12, 9` and its three flags, so nothing ## about the box is decided here. -## ## Crystal only, since pokegold ships neither the routine nor its text. const TILE: int = Gen2Font.TILE @@ -70,7 +68,6 @@ static func menu_box() -> Gen2MenuBox: ## The whole 160x144 page as palette indices, with the arrow on [param cursor]. -## ## `InitGenderScreen` ByteFills the whole tilemap with tile $00, which ## `LoadGenderScreenLightBlueTile` has just filled with one colour, so the field ## behind the box and the menu is that index rather than a blank. diff --git a/game/render/second_screen_host.gd b/game/render/second_screen_host.gd index 9465144c..27e433c6 100644 --- a/game/render/second_screen_host.gd +++ b/game/render/second_screen_host.gd @@ -56,7 +56,6 @@ var _settle: int = PANEL_SETTLE_FRAMES ## Attaches [param view] to whatever second display this build can reach, or ## answers null when there is none. -## ## [param mode] is [member Gen2Options.second_screen]. The view is not built ## here: the world owns it, because it mirrors the world, and this only decides ## where it is drawn. @@ -192,7 +191,6 @@ func _attach_window() -> bool: ## The largest hardware-pixel canvas that fills [param panel] at a whole scale. -## ## Whole numbers only, for the reason [Gen2Screen] gives: a hardware pixel drawn ## as 6.43 screen pixels crawls. The leftover is a bar the far side fills with ## the field colour, and it is never more than one scale factor wide. diff --git a/game/render/town_map_page.gd b/game/render/town_map_page.gd index 4f8b716e..129894b5 100644 --- a/game/render/town_map_page.gd +++ b/game/render/town_map_page.gd @@ -254,7 +254,6 @@ func _load_sheet( ## The whole screen as tile numbers, in the order the source writes them: the ## region map over everything, then the screen's own frame, then the name. -## ## [param cards] is `wPokegearFlags`, as the card names `Pokegear_FinishTilemap` ## tests; it is read only by the Pokegear card's frame. [param name_codes] is the ## landmark's name, or the dex area's whole `'S NEST` header. @@ -586,7 +585,6 @@ func attributes(data: GameData, map: PackedInt32Array) -> PackedInt32Array: ## The whole screen as pixels, each tile coloured through the palette ## `TownMapPals` gave it. [param female] is Kris's own city colours. -## ## This cannot go through [method Gen2PicImage.from_indices] whole: the screen is ## six palettes at once. func image( diff --git a/game/rom/rom_registry.gd b/game/rom/rom_registry.gd index 97d9581f..dbc030c3 100644 --- a/game/rom/rom_registry.gd +++ b/game/rom/rom_registry.gd @@ -27,28 +27,29 @@ const CRYSTAL: StringName = &"crystal" ## sha1 (lowercase hex) -> { id, title, revision, generation, playable } ## `playable` is false while a generation has an importer but no story to walk: -## the launcher seats such a cartridge and reads it, and offers no Play. +## the launcher seats such a cartridge and reads it, and offers no Play. Every +## cartridge here walks from the bedroom to its Hall of Fame. const BY_SHA1: Dictionary = { "ea9bcae617fdf159b045185467ae58b2e4a48b9a": { "id": RED, "title": "Red", "revision": "USA/Europe", "generation": GEN1, - "playable": false, + "playable": true, }, "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2": { "id": BLUE, "title": "Blue", "revision": "USA/Europe", "generation": GEN1, - "playable": false, + "playable": true, }, "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1": { "id": YELLOW, "title": "Yellow", "revision": "USA/Europe", "generation": GEN1, - "playable": false, + "playable": true, }, "d8b8a3600a465308c9953dfa04f0081c05bdcb94": { "id": GOLD, diff --git a/game/rom/rom_verifier.gd b/game/rom/rom_verifier.gd index ac54064c..766f3f56 100644 --- a/game/rom/rom_verifier.gd +++ b/game/rom/rom_verifier.gd @@ -2,7 +2,6 @@ class_name RomVerifier extends RefCounted ## Identifies a user-supplied ROM file by SHA-1 against [RomRegistry]. -## ## Pure and node-free so the whole import gate is testable headlessly. Nothing ## here reads game content; it only answers "is this a cartridge we know?". ## The importer that follows is entitled to assume a verified hash. @@ -41,7 +40,6 @@ static func sha1_of_file(path: String) -> String: ## Identifies a candidate ROM. -## ## Returns { status, sha1, id, title, revision, message }. Callers should look ## at [code]status[/code]; [code]message[/code] is a ready-to-show string. static func identify(path: String) -> Dictionary: diff --git a/game/save/save_screen.gd b/game/save/save_screen.gd index c8f07d61..41bc4466 100644 --- a/game/save/save_screen.gd +++ b/game/save/save_screen.gd @@ -2,11 +2,9 @@ class_name Gen2SaveScreen extends Control ## Player-facing save selection for one imported cartridge revision. -## ## The screen only coordinates validated save data. Original SRAM bytes enter ## through [Gen2SramAdapter], and project slots are written through ## [Gen2SaveStore], so no control here needs to know a cartridge offset. -## ## It is the launcher's second screen and shares its frame: same shell, same ## soft surfaces, same palette. diff --git a/game/save/sram_adapter.gd b/game/save/sram_adapter.gd index 266b2c58..fa139eb3 100644 --- a/game/save/sram_adapter.gd +++ b/game/save/sram_adapter.gd @@ -3,7 +3,6 @@ extends RefCounted ## Boundary between an original Generation 2 SRAM image and the project's ## party-focused save model. -## ## Only an existing, checksummed cartridge image is written. The canonical save ## model does not own map, options, inventory, PC boxes or event flags, so ## creating those bytes from scratch would invent game state. Bytes outside the @@ -216,7 +215,6 @@ static func export_bytes( ## Refuses a save holding content a cartridge byte cannot name. -## ## Every species, item and move on the hardware is one byte, and ## [constant Gen2ContentOverlay.FIRST_MOD_NUMBER] sits past that on purpose. So a ## mod's own content has no representation here: truncating it would write a diff --git a/game/world/game_freak_presents.gd b/game/world/game_freak_presents.gd index ad1de7df..7de3b74f 100644 --- a/game/world/game_freak_presents.gd +++ b/game/world/game_freak_presents.gd @@ -199,7 +199,6 @@ func word_positions() -> Array[Vector2i]: ## What the last `PlaySpriteAnimations` pass left in shadow OAM, as ## [code]{ kind, at, set, flip_y }[/code] in shadow-OAM coordinates. -## ## The buffer, not the live structs: `GameFreakPresentsInit` spawns one without ## writing shadow OAM, and a struct the scene half of a pass spawns is only drawn ## by the sprite half, which on Gold and Silver runs first and so does not see it @@ -211,7 +210,6 @@ func sprites() -> Array[Dictionary]: ## Which `GameFreakDittoPaletteFade` colour the Ditto is wearing, or -1 before ## `GameFreakLogo_Transform` has written one. -## ## Applied in the pass that computes it, like the shadow OAM beside it: ## `hCGBPalUpdate` and hDMATransfer are both serviced by the same VBlank, so the ## palette and the sprites reach the screen together and neither is delayed here. diff --git a/game/world/intro_movie.gd b/game/world/intro_movie.gd index 6d288b62..b9a9fe6e 100644 --- a/game/world/intro_movie.gd +++ b/game/world/intro_movie.gd @@ -11,7 +11,6 @@ extends RefCounted ## Crystal only: `GoldSilverIntro` is a different movie. ## `wBGPals2` and the `wOBPals2` behind it, which is what the screen shows. -## ## Every scene's `ld bc, 16 palettes` runs off the end of the background ## buffer and into the object one, which is where the sprites get their colours: ## the Unown's purple, Pichu's yellow and Wooper's blue are all in the second @@ -630,7 +629,6 @@ func _scene_reset_counter() -> void: ## The half of a setup scene every one of them shares: clear the palettes, the ## sprites and the tilemap, load this scene's own VRAM and palette run, put the ## scroll back at the origin and hand on. -## ## `Intro_ClearBGPals` spends two `DelayFrame`s of its own, which is why a setup ## scene is not free. func _setup_scene() -> void: @@ -1194,7 +1192,6 @@ func _run_sprites() -> void: ## `SpriteAnimFunc_IntroSuicune`: still until `wIntroSceneTimer` is set, then a ## jump on a sine of amplitude 32 and the second frameset under it. -## ## The reinit is unconditional in the source and has to stay that way: ## `_ReinitSpriteAnimFrame` puts FRAME back to -1 and DURATION to 0 every frame, ## so the jump holds `.Frameset_IntroSuicune2`'s first entry for the whole leap diff --git a/game/world/move_deleter.gd b/game/world/move_deleter.gd index 37311690..76e80f39 100644 --- a/game/world/move_deleter.gd +++ b/game/world/move_deleter.gd @@ -2,7 +2,6 @@ class_name Gen2MoveDeleter extends RefCounted ## `MoveDeletion` (`engine/events/move_deleter.asm`), the rules half. -## ## Same shape as [Gen2NameRater], one house further on: an introduction and a ## `YesNoBox`, the party list `SelectMonFromParty` opens, two endings that need ## no move, `ChooseMoveToDelete`'s own list, a second `YesNoBox` and @@ -21,7 +20,6 @@ const SFX_MOVE_DELETED: int = 0x97 ## Which ending [param mon] reaches once it has been chosen, or `&""` for the ## one member the routine carries on with. -## ## `.onlyonemove` reads `wPartyMon1Moves + 1`, which is the *second* slot rather ## than a move count, so a member whose second slot is empty is refused whatever ## stands behind it. diff --git a/game/world/pokegear_screen.gd b/game/world/pokegear_screen.gd index 63699930..21baf1dd 100644 --- a/game/world/pokegear_screen.gd +++ b/game/world/pokegear_screen.gd @@ -30,7 +30,6 @@ const CARD_MAP: StringName = &"map" ## engine/pokegear/pokegear.asm's card order. Each is behind its own ## wPokegearFlags bit, named by the engine flag that carries it, since that is ## what the state holds. The clock card needs no flag. -## ## Here rather than in the screen that lists them, because the Pokegear is not ## the only thing that has to know which cards a player owns. const CARDS: Array[Dictionary] = [ @@ -146,7 +145,6 @@ func _ready() -> void: ## names `Pokegear_FinishTilemap` tests, [param text] whichever of the Pokegear's ## own texts the card prints: the question it opens with and, on the phone card, ## `PokegearAskDeleteText`. -## ## Optional the way the region map is: a cache with no card tilemaps answers ## false and the caller keeps its own screen open. func open( diff --git a/game/world/prof_oaks_pc.gd b/game/world/prof_oaks_pc.gd index fecc6f8e..de082c55 100644 --- a/game/world/prof_oaks_pc.gd +++ b/game/world/prof_oaks_pc.gd @@ -3,7 +3,6 @@ extends RefCounted ## `ProfOaksPCBoot`, `ProfOaksPCRating` and `Rate` (engine/events/prof_oaks_pc.asm), ## as the pages a screen shows and the effect it plays. -## ## Presentation only: the routine counts `wPokedexSeen` and `wPokedexCaught` and ## writes nothing back. The pages are the source's own order, and each waits for ## A or B, which is why the sound is on the last of them rather than on the whole @@ -19,7 +18,6 @@ const COUNT_DIGITS: int = 3 ## The whole boot, as [code]{ seen, caught, sfx, pages }[/code]. Empty for a ## cache imported without the rating table, which is the caller's cue to leave ## the script's own `end` alone. -## ## `ProfOaksPCBoot` is `_OakPCText2` in front of `Rate`. static func boot(data: GameData, state: Gen2WorldState) -> Dictionary: var out: Dictionary = rate(data, state) diff --git a/game/world/trainer_card.gd b/game/world/trainer_card.gd index fcb83cb8..51f29078 100644 --- a/game/world/trainer_card.gd +++ b/game/world/trainer_card.gd @@ -3,7 +3,6 @@ extends RefCounted ## What the trainer card prints (`engine/menus/trainer_card.asm`), as pages a ## screen can draw. -## ## Everything here comes from the save and the world the card is opened over: ## `wPlayerName`, `wPlayerID` and `wMoney` on the top half, `wPokedexCaught`'s ## set bits and `wGameTimeHours`/`wGameTimeMinutes` on page 1, and the two badge @@ -85,7 +84,6 @@ static func badges(state: Gen2WorldState, page_number: int, crystal: bool) -> Ar ## `TrainerCard_Page1_Joypad`, `..._Page2_Joypad` and `..._Page3_Joypad` as one ## table: which page a direction reaches, and whether A leaves. -## ## Page 1 answers right and A with page 2; page 2 answers left with page 1 and A ## with the exit; page 3 answers left with page 2 and right with page 1. The two ## `.KantoBadgeCheck` branches that would reach page 3 are unreferenced in both diff --git a/game/world/world_animation.gd b/game/world/world_animation.gd index b5adc943..5d88dcfe 100644 --- a/game/world/world_animation.gd +++ b/game/world/world_animation.gd @@ -2,7 +2,6 @@ class_name Gen2WorldAnimation extends RefCounted ## Small interpreter for the command tables used by the cartridge overworld. -## ## The original engine writes 2bpp tiles to VRAM. This node-free equivalent ## keeps the same command order and timer semantics, but applies those writes to ## the indexed tile strip held by GameData. @@ -22,7 +21,6 @@ const TIMER_TOWER_FRAMES: Array = [0, 1, 2, 3, 4, 3, 2, 1] const TIMER_WATER_PALETTE: Array = [0, 1, 2, 1] ## Real seconds into hardware frames, for every screen that spends them. -## ## One instance per pump, and the whole conversion: the remainder is banked here ## so nothing downstream keeps one, the cap is applied here so no screen can ## forget it, and [method Gen2Options.speed_scale] is applied here and nowhere else. @@ -252,7 +250,6 @@ func reload_tileset(world: Gen2WorldAPI, time_of_day: int = Gen2WorldPalette.TIM ## Runs one hardware frame's command and reports whether the tile strip or a ## palette row actually changed. -## ## Most commands are waits and timer bumps that draw nothing new, so the return ## value is what a renderer should gate its atlas rebuild on; [method tick] is ## the single-command step the sequence is defined in. diff --git a/game/world/world_api.gd b/game/world/world_api.gd index 7f5eac91..3fc7fbbd 100644 --- a/game/world/world_api.gd +++ b/game/world/world_api.gd @@ -5100,7 +5100,11 @@ func _gen1_node_heal_party(_node: Dictionary, steps: Array, _run: Dictionary) -> return true -func _gen1_node_hall_of_fame(_node: Dictionary, steps: Array, _run: Dictionary) -> bool: +## `HallOfFamePC` writes the team into `sHallOfFame` and counts it in +## `wNumHoFTeams` in front of the animation, which is the induction the shelf +## and the Pokemon Center's machine read as `ENGINE_HALL_OF_FAME`. +func _gen1_node_hall_of_fame(_node: Dictionary, steps: Array, run: Dictionary) -> bool: + _gen1_node_flag({"flag": Gen2WorldState.ENGINE_HALL_OF_FAME, "engine": true, "set": true}, steps, run) steps.append({"type": &"request", "values": { "kind": &"hall_of_fame_requested", "values": {}, }}) @@ -5157,11 +5161,12 @@ func _gen1_node_map_text(node: Dictionary, steps: Array, run: Dictionary) -> boo var text_id: int = int(node["text"]) if node.has("text") \ else _gen1_scratch_read(run, int(node["from"]), int(node["offset"])) ## `hTextID` and `hSpriteIndex` are one HRAM byte, so a row a map script - ## opens by id leaves that id where `EngageMapTrainer` reads a sprite. + ## opens by id leaves that id where `EngageMapTrainer` reads a sprite: the + ## object of that index is the trainer, which is how Lance's room fights him. _gen1_last_sprite_index = text_id - 1 var row: Dictionary = gen1_text_at(text_id) ## `DisplayTextID` over a trainer's row is `TalkToTrainer` after the fight. - var trainer: Array = _gen1_trainer_steps(row, {}) + var trainer: Array = _gen1_trainer_steps(row, _gen1_object_event(text_id - 1)) if not trainer.is_empty(): steps.append_array(trainer) return true @@ -7251,6 +7256,22 @@ func _clear_active_script() -> void: unfreeze_all_objects() +## The object row of [param index] with its live cell, as the events at a cell +## carry it, or {} past the table. +func _gen1_object_event(index: int) -> Dictionary: + var rows: Array = current_map.events.get("objects", []) if current_map != null else [] + if index < 0 or index >= rows.size() or index >= objects.size() \ + or not rows[index] is Dictionary: + return {} + var object: Gen2WorldObject = objects[index] + var event: Dictionary = (rows[index] as Dictionary).duplicate(true) + event["x"] = object.cell.x + event["y"] = object.cell.y + event["kind"] = &"objects" + event["object_index"] = index + return event + + func _active_events_at(cell: Vector2i) -> Array: var out: Array = [] for event: Dictionary in events_at(cell): @@ -7275,12 +7296,7 @@ func _active_events_at(cell: Vector2i) -> Array: or object.index < 0 or object.index >= rows.size() \ or not rows[object.index] is Dictionary: continue - var event: Dictionary = (rows[object.index] as Dictionary).duplicate(true) - event["x"] = object.cell.x - event["y"] = object.cell.y - event["kind"] = &"objects" - event["object_index"] = object.index - out.append(event) + out.append(_gen1_object_event(object.index)) return out diff --git a/game/world/world_bag_host.gd b/game/world/world_bag_host.gd index 9a5fa03c..426fbe2a 100644 --- a/game/world/world_bag_host.gd +++ b/game/world/world_bag_host.gd @@ -10,7 +10,6 @@ extends RefCounted ## stacks. The commit boundary is [Gen2WorldTransaction]. ## `TossItem` with `wCurItemQuantity`, as one validated transaction. -## ## `TossMenu` is only reachable from a submenu that offered TOSS, so the ## permission is already settled by the time the source gets here; it is checked ## again because a caller is not always that menu. @@ -223,7 +222,6 @@ static func register( ## `SwitchItemsInBag` as a committed transaction: one pocket's rows put in ## [param pocket_order], with the rest of the bag left where it sits. -## ## The caller has already run [method Gen2WorldPack.switch_items] to work out the ## order; this only writes it. `wPCItems` is the same routine over a different ## list, which [param pc] selects. diff --git a/game/world/world_effects.gd b/game/world/world_effects.gd index d5f3830e..5096feae 100644 --- a/game/world/world_effects.gd +++ b/game/world/world_effects.gd @@ -224,7 +224,6 @@ func start_headbutt_tree(cell: Vector2i) -> void: ## the splitting tree and index 1 the four leaves (`Gen2WorldFieldMove`'s ## ANIMATION_TREE and ANIMATION_GRASS), which is the byte ## `CheckOverworldTileArrays` returns beside the replacement block. -## ## [param player_cell] is where the player stands, which is what picks the ## leaves' own corner of the block. func start_cut( @@ -305,7 +304,6 @@ func start_boulder_dust(object_index: int, cell: Vector2i, direction: Vector2i, ## sprite: its OAM is written at fixed screen pixels while the script waits, and ## it wears `gfx/overworld/heal_machine.pal` over `wOBPals2`' PAL_OW_TREE slot ## rather than an overworld palette, which is what `palette: -1` says here. -## ## [param balls] is `wPartyCount`; the source returns before writing anything ## when it is zero. func start_heal_machine( diff --git a/game/world/world_radio.gd b/game/world/world_radio.gd index dd86a88c..d2a0d03d 100644 --- a/game/world/world_radio.gd +++ b/game/world/world_radio.gd @@ -96,7 +96,6 @@ const CHANNELS: Array[Dictionary] = [ ## Let's All Sing's, though only `PlayRadioStationPointers` reaches it and a map ## radio prints no name. `LoadStation_BuenasPassword` answers with ## `NotBuenasPasswordName`, an empty string, until Team Rocket takes the tower. -## ## `.returnafterstation` places this before `PlayRadioShow` runs, so the name is ## the station the dial is on and never the Rocket broadcast standing in for it. const STATION_NAMES: Dictionary = { @@ -164,7 +163,6 @@ static func is_kanto_landmark(landmark: int, crystal: bool = true) -> bool: ## The station a knob position answers with, or a no-signal result. -## ## [param context] carries what the source reads off WRAM: `landmark` (already ## resolved through GetWorldMapLocation), `crystal`, `expn_card`, ## `rocket_signal`, `rockets_in_radio_tower` and `time_of_day`. diff --git a/game/world/world_screen.gd b/game/world/world_screen.gd index d79dadfb..7c6874ad 100644 --- a/game/world/world_screen.gd +++ b/game/world/world_screen.gd @@ -6284,6 +6284,9 @@ func _on_credits_closed() -> void: _renderer.refresh() _script_prompt = "" _refresh_labels() + if _world != null and StringName(_world.pending_runtime_request().get("kind", &"")) \ + == &"hall_of_fame_requested": + _show_script_results(_world.complete_runtime_request({"ok": true})) ## `.music`, whose `PlayMusic MUSIC_NONE` and `DelayFrame` in front of the real @@ -7703,6 +7706,8 @@ const REQUEST_HANDLERS: Dictionary = { &"swarm_requested": &"_request_swarm", &"map_radio_requested": &"_request_map_radio", &"audio_requested": &"_request_audio", + &"hall_of_fame_requested": &"_request_hall_of_fame", + &"soft_reset_requested": &"_request_soft_reset", } ## Runtime requests the screen answers by opening a page: the method that opens @@ -8069,6 +8074,22 @@ func _request_bug_contest_judging(_request: Dictionary) -> StringName: ## `TryQuickSave`, which is `Link_SaveGame`: the overwrite question, the SAVING ## box and `SavedTheGame`, on the service screen where BILL'S PC's already are. A ## driver with no scene behind it still writes rather than hanging. +## `HallOfFamePC`, which Generation 1's own room script calls and waits on: the +## induction and the credits, and the script's save and reset behind them run +## when the credits close. +func _request_hall_of_fame(_request: Dictionary) -> StringName: + open_hall_of_fame() + return &"break" + + +## `jp Init` at the end of `HallOfFameResetEventsAndSaveScript`, a request where +## Crystal's `special Reset` is an event. +func _request_soft_reset(_request: Dictionary) -> StringName: + persist_world_snapshot() + _soft_reset() + return &"break" + + func _request_quick_save(_request: Dictionary) -> StringName: if _service_host == null and _open_quick_save_screen(): return &"break" diff --git a/game/world/world_tileset.gd b/game/world/world_tileset.gd index 99335e58..ed53edb4 100644 --- a/game/world/world_tileset.gd +++ b/game/world/world_tileset.gd @@ -2,7 +2,6 @@ class_name Gen2WorldTileset extends RefCounted ## Runtime data for one overworld tileset. -## ## Graphics are kept in the cache as an indexed tile strip and loaded lazily by ## GameData. The metatile and collision tables stay here because map expansion ## needs them without opening the cartridge. @@ -90,7 +89,6 @@ func collision_index(block: int, cell_x: int, cell_y: int) -> int: ## Which of the eight background palette slots a tile of the strip draws with. -## ## Palette maps store two assignments per byte, low nibble first, over the whole ## 224-tile span. Bit 3 of a nibble is the VRAM bank the cartridge draws that ## tile from and is dropped here: the strip is flat, so the tile number already diff --git a/tests/unit/test_launcher_ui.gd b/tests/unit/test_launcher_ui.gd index ac4822a3..bd6aacd7 100644 --- a/tests/unit/test_launcher_ui.gd +++ b/tests/unit/test_launcher_ui.gd @@ -746,26 +746,25 @@ func test_ejecting_a_cartridge_empties_its_bay() -> void: assert_almost_eq(card.position.y, card.rest_y(), 0.5, "and back where it stood") -## A generation with an importer and no world reads its cartridge and cannot -## start it, so the bar must not offer Play on a seated one. -func test_a_seated_but_unplayable_cartridge_offers_nothing_to_press() -> void: +## Every registry cartridge walks to its Hall of Fame, so a seated one of +## either generation offers Play, and an empty bay still takes a dump. +func test_a_seated_cartridge_of_either_generation_offers_play() -> void: var page: Gen2ShelfPage = Gen2ShelfPage.create(_light, false) add_child_autofree(page) page.size = Vector2(900, 600) await get_tree().process_frame - # Unanimated, because the depth a bay reads is only settled once the slide is. - page.stage().select(RomRegistry.ORDER.find(RomRegistry.RED), false) - await get_tree().process_frame - - page.set_slot_state(RomRegistry.RED, RomCache.STATE_MISSING, "") - assert_eq(page.hints()[0]["label"], "Add cartridge", "an empty bay still takes a dump") - - page.set_slot_state(RomRegistry.RED, RomCache.STATE_USABLE, "Ready") - var labels: Array = [] - for entry: Dictionary in page.hints(): - labels.append(entry["label"]) - assert_false(labels.has("Play"), "Red cannot be played yet") - assert_true(labels.has("Options"), "and its cache is still reachable") + for id: StringName in [RomRegistry.RED, RomRegistry.GOLD]: + # Unanimated, because the depth a bay reads is only settled once the slide is. + page.stage().select(RomRegistry.ORDER.find(id), false) + await get_tree().process_frame + page.set_slot_state(id, RomCache.STATE_MISSING, "") + assert_eq(page.hints()[0]["label"], "Add cartridge", "an empty bay still takes a dump") + page.set_slot_state(id, RomCache.STATE_USABLE, "Ready") + var labels: Array = [] + for entry: Dictionary in page.hints(): + labels.append(entry["label"]) + assert_true(labels.has("Play"), "%s can be played" % id) + assert_true(labels.has("Options"), "and its cache is still reachable") ## A cache an older build wrote is not an empty bay. The player imported this diff --git a/tests/unit/test_source_budget.gd b/tests/unit/test_source_budget.gd index bda0aa50..a9102fd2 100644 --- a/tests/unit/test_source_budget.gd +++ b/tests/unit/test_source_budget.gd @@ -21,7 +21,7 @@ const MAX_COMMENT_BLOCK: int = 8 ## the shared code, the largest of them 152 the region map, 150 the battle ## animation engine, 140 the transition, 131 the field moves, 130 the Pokedex, ## 108 the three PCs, 90 the Safari Zone, 52 the menus a row draws. -const MAX_COMMENT_LINES: int = 41935 +const MAX_COMMENT_LINES: int = 41932 ## What no comment block ever ends on. A pass that meets the ceiling above trims ## the second line of a two-line block and leaves the first mid-sentence, which diff --git a/tools/checks/gen1_maps.gd b/tools/checks/gen1_maps.gd index 1ee51c5e..962e3a44 100644 --- a/tools/checks/gen1_maps.gd +++ b/tools/checks/gen1_maps.gd @@ -224,7 +224,7 @@ const CALLBACK_CENSUS: Dictionary = { ## every `set_map_script` already read, and the bodies the walker gets whole. const STATE_CENSUS: Dictionary = { &"red": {"tables": 98, "states": 373, "read": 183, "branch": 71, "player_coord": 53, - "player_facing": 58, "flag": 264, "set_map_script": 313, "save_coord_index": 9, + "player_facing": 58, "flag": 264, "set_map_script": 318, "save_coord_index": 9, "map_text": 142, "toggle_object": 155, "object_facing": 75, "set_player_coord": 1, "object_path": 2, "movement_running": 70, "npc_movement_script": 1, "movement_script_running": 4, "flag_test": 15, "walk": 47, "badges_byte": 1, @@ -232,14 +232,14 @@ const STATE_CENSUS: Dictionary = { "riding": 4, "coord_index": 18, "starter": 22, "trainer_battle": 30, "battle_outcome": 34, "object_stay": 12, "facing": 3, "has_item": 4, "emote": 2, "saved_coord_index": 16, "badge_guards": 1, "scratch_test": 4, "set_starter": 3, - "name_species": 3, "heal_party": 2, "scratch": 13, "give_item": 9, - "arrow_movement": 3, "guard_drink": 4, "boulder_on": 3, "map_load_bit": 10, - "hall_of_fame": 1, "set_blackout_map": 1, "save_game": 1, "reset_game": 1, - "object_coord_move": 1, "warp_to": 1, "set_last_map": 1, "safari_balls": 1, - "set_riding": 1, "replace_block": 24, "volatile": 1, "volatile_test": 1, - "coord_lookup": 3, "trainer_battle_object": 1}, + "name_species": 3, "heal_party": 2, "scratch": 13, "give_item": 9, "arrow_movement": 3, + "guard_drink": 4, "boulder_on": 3, "map_load_bit": 10, "hall_of_fame": 1, + "set_blackout_map": 1, "save_game": 1, "reset_game": 1, "object_coord_move": 1, + "warp_to": 1, "set_last_map": 1, "safari_balls": 1, "set_riding": 1, + "replace_block": 24, "volatile": 1, "volatile_test": 1, "coord_lookup": 3, + "trainer_battle_object": 1}, &"blue": {"tables": 98, "states": 373, "read": 183, "branch": 71, "player_coord": 53, - "player_facing": 58, "flag": 264, "set_map_script": 313, "save_coord_index": 9, + "player_facing": 58, "flag": 264, "set_map_script": 318, "save_coord_index": 9, "map_text": 142, "toggle_object": 155, "object_facing": 75, "set_player_coord": 1, "object_path": 2, "movement_running": 70, "npc_movement_script": 1, "movement_script_running": 4, "flag_test": 15, "walk": 47, "badges_byte": 1, @@ -247,27 +247,26 @@ const STATE_CENSUS: Dictionary = { "riding": 4, "coord_index": 18, "starter": 22, "trainer_battle": 30, "battle_outcome": 34, "object_stay": 12, "facing": 3, "has_item": 4, "emote": 2, "saved_coord_index": 16, "badge_guards": 1, "scratch_test": 4, "set_starter": 3, - "name_species": 3, "heal_party": 2, "scratch": 13, "give_item": 9, - "arrow_movement": 3, "guard_drink": 4, "boulder_on": 3, "map_load_bit": 10, - "hall_of_fame": 1, "set_blackout_map": 1, "save_game": 1, "reset_game": 1, - "object_coord_move": 1, "warp_to": 1, "set_last_map": 1, "safari_balls": 1, - "set_riding": 1, "replace_block": 24, "volatile": 1, "volatile_test": 1, - "coord_lookup": 3, "trainer_battle_object": 1}, + "name_species": 3, "heal_party": 2, "scratch": 13, "give_item": 9, "arrow_movement": 3, + "guard_drink": 4, "boulder_on": 3, "map_load_bit": 10, "hall_of_fame": 1, + "set_blackout_map": 1, "save_game": 1, "reset_game": 1, "object_coord_move": 1, + "warp_to": 1, "set_last_map": 1, "safari_balls": 1, "set_riding": 1, + "replace_block": 24, "volatile": 1, "volatile_test": 1, "coord_lookup": 3, + "trainer_battle_object": 1}, &"yellow": {"tables": 98, "states": 411, "read": 226, "branch": 123, "player_coord": 86, - "flag": 306, "player_facing": 63, "set_map_script": 380, "save_coord_index": 15, + "flag": 306, "player_facing": 63, "set_map_script": 384, "save_coord_index": 15, "map_text": 181, "object_position": 28, "toggle_object": 195, "object_facing": 94, "set_player_coord": 1, "object_path": 2, "movement_running": 83, "wild_battle": 6, - "npc_movement_script": 1, "movement_script_running": 4, "flag_test": 20, - "walk": 61, "badges_byte": 6, "object_move": 102, "player_in_array": 38, - "riding": 4, "coord_index": 18, "trainer_battle": 17, "battle_outcome": 38, - "object_stay": 17, "facing": 4, "has_item": 4, "emote": 2, "saved_coord_index": 18, - "starter": 1, "set_starter": 4, "badge_guards": 1, "name_species": 1, - "heal_party": 3, "scratch": 16, "give_item": 9, "arrow_movement": 3, - "guard_drink": 4, "volatile_test": 3, "volatile": 8, "random": 1, "boulder_on": 3, - "map_load_bit": 10, "hall_of_fame": 1, "set_blackout_map": 1, "save_game": 1, - "reset_game": 1, "warp_to": 1, "set_last_map": 1, "safari_balls": 1, - "set_riding": 1, "scratch_test": 3, "replace_block": 36, "coord_lookup": 3, - "trainer_battle_object": 2}, + "npc_movement_script": 1, "movement_script_running": 4, "flag_test": 20, "walk": 61, + "badges_byte": 6, "object_move": 102, "player_in_array": 38, "riding": 4, + "coord_index": 18, "trainer_battle": 17, "battle_outcome": 38, "object_stay": 17, + "facing": 4, "has_item": 4, "emote": 2, "saved_coord_index": 18, "starter": 1, + "set_starter": 4, "badge_guards": 1, "name_species": 1, "heal_party": 3, "scratch": 16, + "give_item": 9, "arrow_movement": 3, "guard_drink": 4, "volatile_test": 3, + "volatile": 8, "random": 1, "boulder_on": 3, "map_load_bit": 10, "hall_of_fame": 1, + "set_blackout_map": 1, "save_game": 1, "reset_game": 1, "warp_to": 1, + "set_last_map": 1, "safari_balls": 1, "set_riding": 1, "scratch_test": 3, + "replace_block": 36, "coord_lookup": 3, "trainer_battle_object": 2}, } ## The pin on which way a `wCurrentMenuItem` branch reads. diff --git a/tools/checks/gen1_walk.gd b/tools/checks/gen1_walk.gd index 1aa6899b..973b78f2 100644 --- a/tools/checks/gen1_walk.gd +++ b/tools/checks/gen1_walk.gd @@ -519,6 +519,8 @@ func _one_game() -> void: _check_the_gate_pushes_back() _check_cinnabar_settles() _check_a_mansion_switch() + _check_an_elite_room_settles() + _check_lances_trigger() ## `DisplayPokemonCenterDialogue_` walked whole. `AnimateHealingMachine` is a @@ -2623,6 +2625,89 @@ func _check_a_scripted_player_walk() -> void: _r.check(world.player_facing == Gen2WorldSprite.FACING_RIGHT, "the player faced %d rather than Oak." % world.player_facing) _r.note("gen1 walk HALL_OF_FAME five cells in and Oak turning to meet it") + _check_the_induction(world) + + +## `HallOfFameResetEventsAndSaveScript` behind Oak's box: `HallOfFamePC` is a +## request the screen answers with the induction, and the flag the shelf reads +## stands before it; the Plateau's events, the save and `jp Init` follow. +func _check_the_induction(world: Gen2WorldAPI) -> void: + world.state.set_event_flag(BEAT_CHAMPION_RIVAL_FLAG) + var request: Dictionary = _pressed_to_request(world) + if not _r.check(StringName(request.get("kind", &"")) == &"hall_of_fame_requested", + "Oak's box was followed by %s." % [request]): + return + _r.check(world.state.hall_of_fame(), "ENGINE_HALL_OF_FAME is clear at the induction.") + world.complete_runtime_request({"ok": true}) + var kinds: Array = [] + for _request: int in 3: + var next: Dictionary = _pressed_to_request(world) + if next.is_empty(): + break + kinds.append(StringName(next["kind"])) + if StringName(next["kind"]) == &"soft_reset_requested": + break + world.complete_runtime_request({"ok": true, "script_value": 1}) + _r.check(kinds == [&"quick_save_requested", &"soft_reset_requested"], + "the induction was followed by %s." % [kinds]) + _r.check(not world.event_flag_active(BEAT_CHAMPION_RIVAL_FLAG), + "the Plateau's events were not cleared.") + + +## A pressed through every box until a runtime request stands, or {}. +func _pressed_to_request(world: Gen2WorldAPI) -> Dictionary: + for _pass: int in SCRIPTED_WALK_PASSES: + if not world.pending_runtime_request().is_empty(): + return world.pending_runtime_request() + if world.pending_script_input().is_empty(): + world.dispatch_sight_events() + else: + world.run_event_queue(true) + return world.pending_runtime_request() + + +## `LoreleisRoomLoreleiEndBattleScript` calls `EndTrainerBattle`, whose +## `ResetButtonPressedAndMapScript` zeroes `wCurMapScript`, so the after-battle +## line prints once and the room settles on its default state. +func _check_an_elite_room_settles() -> void: + var world: Gen2WorldAPI = _r.open_world(0, LORELEIS_ROOM, LORELEI_SIDE) + if world == null: + return + world.state.set_event_flag(AUTOWALKED_INTO_LORELEIS_ROOM_FLAG) + world.dispatch_map_entry() + world.player_facing = Gen2WorldSprite.FACING_UP + world.interact() + var request: Dictionary = _pressed_to_request(world) + if not _r.check(StringName(request.get("kind", &"")) == &"battle_requested", + "Lorelei asked for %s." % [request]): + return + world.complete_runtime_request({"ok": true, "outcome": Gen2WorldBattleAdapter.OUTCOME_WON}) + var boxes: int = 0 + for _pass: int in 6: + if not world.pending_script_input().is_empty(): + boxes += 1 + world.run_event_queue(true) + else: + world.dispatch_sight_events() + _r.check(world.state.gen1_map_script(LORELEIS_ROOM_BYTE) == 0, + "the room stayed on state %d." % world.state.gen1_map_script(LORELEIS_ROOM_BYTE)) + _r.check(boxes == 1, "the after-battle line printed %d times." % boxes) + _r.check(world.event_flag_active(BEAT_LORELEI_FLAG), "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0 is clear.") + _r.note("gen1 walk LORELEIS_ROOM's end-battle state hands back to the default one") + + +## `DisplayTextID` by id: `hTextID` is `hSpriteIndex`, so the object of that +## index is the trainer `TalkToTrainer` fights, which is how Lance's own +## coordinate trigger opens a trainer battle rather than a wild one. +func _check_lances_trigger() -> void: + var world: Gen2WorldAPI = _r.open_world(0, LANCES_ROOM, LANCE_TRIGGER) + if world == null: + return + world.dispatch_map_entry() + var values: Dictionary = _pressed_to_request(world).get("values", {}) as Dictionary + _r.check(StringName(values.get("kind", &"")) == &"trainer" + and int(values.get("trainer_class", 0)) == LANCE_CLASS, + "Lance's trigger asked for %s." % [values]) ## `RemoveGuardDrink` driven on the world: the guard is thirsty with an empty @@ -3340,6 +3425,14 @@ func _check_the_viridian_gym_door() -> void: _r.note("gen1 walk VIRIDIAN_CITY's gym door with and without seven badges") +const LORELEIS_ROOM: int = 245 +const LORELEIS_ROOM_BYTE: int = 93 +const LORELEI_SIDE := Vector2i(5, 3) +const AUTOWALKED_INTO_LORELEIS_ROOM_FLAG: int = 2278 +const BEAT_LORELEI_FLAG: int = 2273 +const LANCES_ROOM: int = 113 +const LANCE_TRIGGER := Vector2i(6, 2) +const LANCE_CLASS: int = 47 const CINNABAR_ISLAND: int = 8 const CINNABAR_SHORE := Vector2i(19, 13) const POKEMON_MANSION_3F: int = 215 diff --git a/tools/preview_world_story.gd b/tools/preview_world_story.gd index 19d5cdc4..cca97e50 100644 --- a/tools/preview_world_story.gd +++ b/tools/preview_world_story.gd @@ -30,6 +30,9 @@ const REQUEST_HANDLERS: Dictionary = { &"pokedex_entry_requested": &"_request_audio", &"elevator_requested": &"_request_elevator", &"vending_requested": &"_request_vending", + &"hall_of_fame_requested": &"_request_hall_of_fame", + &"quick_save_requested": &"_request_acknowledged", + &"soft_reset_requested": &"_request_soft_reset", } ## The drink the next `VendingMachineMenu` buys, as an item number; 0 is B. @@ -8528,6 +8531,7 @@ func _drain_story( "hall_of_fame": _hall_of_fame_events(results), "credits": _credits_events(results), "texts": _gen1_texts(results), + "soft_reset": false, "reason": "", "details": "", } @@ -8554,6 +8558,7 @@ func _drain_story( "credits": state["credits"], "approaches": state["approaches"], "texts": state["texts"], + "soft_reset": state["soft_reset"], "terminal": String(state["reason"]).is_empty() \ and not world.script_input_waiting() and world.pending_runtime_request().is_empty(), "reason": state["reason"], @@ -8605,6 +8610,25 @@ func _runtime_request(world: Gen2WorldAPI, state: Dictionary) -> Array: return (Callable(self, String(REQUEST_HANDLERS[kind]))).call(world, request, state) +## `HallOfFamePC` on Generation 1 is a request rather than Crystal's event, so +## the induction is counted where the event is and the script carries on. +func _request_hall_of_fame(world: Gen2WorldAPI, _request: Dictionary, state: Dictionary) -> Array: + state["hall_of_fame"] = int(state["hall_of_fame"]) + 1 + return world.complete_runtime_request({"ok": true}) + + +## `SaveGameData`: nothing this walk keeps, and the script goes on. +func _request_acknowledged(world: Gen2WorldAPI, _request: Dictionary, _state: Dictionary) -> Array: + return world.complete_runtime_request({"ok": true, "script_value": 1}) + + +## `jp Init` behind the Hall of Fame's save: the console restarts, so the drain +## is over and the settle behind it does not wait for a map script that never runs. +func _request_soft_reset(world: Gen2WorldAPI, _request: Dictionary, state: Dictionary) -> Array: + state["soft_reset"] = true + return world.complete_runtime_request({"ok": true}) + + func _request_rival_name(world: Gen2WorldAPI, _request: Dictionary, state: Dictionary) -> Array: var named: Dictionary = Gen2WorldHost.complete_runtime_request( world, {"ok": true, "name": "SILVER"}, state["save"], false, state["random"] @@ -8834,6 +8858,8 @@ func _absorb_results(world: Gen2WorldAPI, results: Array, state: Dictionary) -> state["credits"] += _credits_events(results) state["waits"] += 1 _record_failure(results, state) + if bool(state["soft_reset"]): + return true if world.script_input_waiting() or not world.pending_runtime_request().is_empty(): return false for result: Dictionary in results: @@ -9718,6 +9744,77 @@ const GEN1_VIRIDIAN_GIOVANNI := Vector2i(2, 1) const GEN1_EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI: int = 81 const GEN1_EVENT_GOT_TM27: int = 80 const GEN1_BIT_EARTHBADGE: int = 7 +const GEN1_ROUTE_22: int = 33 +const GEN1_ROUTE_23: int = 34 +const GEN1_ROUTE_22_GATE: int = 193 +const GEN1_VICTORY_ROAD_1F: int = 108 +const GEN1_VICTORY_ROAD_2F: int = 194 +const GEN1_VICTORY_ROAD_3F: int = 198 +const GEN1_ROUTE_22_GATE_DOOR := Vector2i(8, 5) +const GEN1_ROUTE_22_GATE_NORTH := Vector2i(4, 0) +const GEN1_ROUTE_23_SOUTH_BANK := Vector2i(11, 104) +const GEN1_ROUTE_23_NORTH_BANK := Vector2i(8, 71) +const GEN1_ROUTE_23_VICTORY_ROAD_DOOR := Vector2i(4, 31) +## 1F's boulder to its switch at (17, 13): down to the entrance row, along it, +## up column 9 from the door cell (whose edge is never faced), along row 14, up +## column 16, right and down. Each row is a step name, the cell pushed from, the +## way and how many times. +const GEN1_VICTORY_ROAD_1F_PUSHES: Array = [ + ["victory_road_1f_push_down", Vector2i(5, 14), Vector2i.DOWN, 1], + ["victory_road_1f_push_right", Vector2i(4, 16), Vector2i.RIGHT, 4], + ["victory_road_1f_push_up", Vector2i(9, 17), Vector2i.UP, 2], + ["victory_road_1f_push_along", Vector2i(8, 14), Vector2i.RIGHT, 7], + ["victory_road_1f_push_up_again", Vector2i(16, 15), Vector2i.UP, 2], + ["victory_road_1f_push_right_again", Vector2i(15, 12), Vector2i.RIGHT, 1], + ["victory_road_1f_push_onto_switch", Vector2i(17, 11), Vector2i.DOWN, 1], +] +const GEN1_EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH: int = 2327 +const GEN1_VICTORY_ROAD_1F_LADDER := Vector2i(1, 1) +## 2F's first switch at (1, 16), which opens the way east: the boulder at (4, 14) +## left, down column 3 and left along the bottom row. +const GEN1_VICTORY_ROAD_2F_PUSHES: Array = [ + ["victory_road_2f_push_left", Vector2i(5, 14), Vector2i.LEFT, 1], + ["victory_road_2f_push_down", Vector2i(3, 13), Vector2i.DOWN, 2], + ["victory_road_2f_push_onto_switch", Vector2i(4, 16), Vector2i.LEFT, 2], +] +const GEN1_EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1: int = 1336 +const GEN1_EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2: int = 1343 +## 3F's boulder into its hole, which `VictoryRoad3FDefaultScript` answers by +## showing 2F's own on (23, 16), and the player after it: that one goes left +## along the bottom row to the second switch at (9, 16), which opens the way to +## the east ladder's own $05 floor. +const GEN1_VICTORY_ROAD_2F_LADDER := Vector2i(23, 7) +## The boulder standing in the wall's one gap at (13, 12), pushed out of it and +## once more off the cell below. +const GEN1_VICTORY_ROAD_3F_GAP_ABOVE := Vector2i(13, 11) +const GEN1_VICTORY_ROAD_3F_HOLE_SIDE := Vector2i(21, 15) +const GEN1_VICTORY_ROAD_3F_HOLE := Vector2i(23, 15) +const GEN1_VICTORY_ROAD_2F_FALLEN_BOULDER_SIDE := Vector2i(24, 16) +const GEN1_VICTORY_ROAD_2F_SECOND_PUSHES: int = 14 +const GEN1_VICTORY_ROAD_2F_EAST_LADDER := Vector2i(25, 14) +const GEN1_VICTORY_ROAD_3F_LADDER_DOWN := Vector2i(26, 8) +const GEN1_VICTORY_ROAD_2F_EXIT := Vector2i(29, 8) +const GEN1_INDIGO_PLATEAU: int = 9 +const GEN1_INDIGO_PLATEAU_LOBBY: int = 174 +const GEN1_INDIGO_PLATEAU_DOOR := Vector2i(9, 5) +const GEN1_LORELEIS_ROOM: int = 245 +const GEN1_BRUNOS_ROOM: int = 246 +const GEN1_AGATHAS_ROOM: int = 247 +const GEN1_LANCES_ROOM: int = 113 +const GEN1_CHAMPIONS_ROOM: int = 120 +const GEN1_LOBBY_STAIRS := Vector2i(8, 0) +## Each of the first three rooms is the same: the member at (5, 2), the door on. +const GEN1_ELITE_MEMBER := Vector2i(5, 2) +const GEN1_ELITE_NEXT_DOOR := Vector2i(4, 0) +const GEN1_LANCE := Vector2i(6, 1) +const GEN1_EVENT_BEAT_LANCE: int = 2302 +const GEN1_LANCES_ROOM_EXIT := Vector2i(5, 0) +const GEN1_HALL_OF_FAME: int = 118 +const GEN1_ELITE_FOUR: Array = [ + ["lorelei", GEN1_BRUNOS_ROOM, 2273, "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0"], + ["bruno", GEN1_AGATHAS_ROOM, 2281, "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0"], + ["agatha", GEN1_LANCES_ROOM, 2289, "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0"], +] const GEN1_BEDROOM_STAIRS := Vector2i(7, 1) const GEN1_HOUSE_DOOR := Vector2i(2, 7) ## `PalletTownDefaultScript` stops the player at `wYCoord == 1`, and Yellow's at 0. @@ -9801,7 +9898,7 @@ func _gen1_story_path(data: GameData) -> Dictionary: _gen1_rocket_hideout_leg, _gen1_pokemon_tower_leg, _gen1_fuchsia_gym_leg, _gen1_safari_zone_leg, _gen1_saffron_drink_leg, _gen1_silph_co_leg, _gen1_saffron_gym_leg, _gen1_cinnabar_leg, _gen1_mansion_leg, _gen1_cinnabar_gym_leg, - _gen1_viridian_gym_leg, + _gen1_viridian_gym_leg, _gen1_route_23_leg, _gen1_victory_road_leg, _gen1_elite_four_leg, ] for leg: Callable in legs: var walked: Dictionary = leg.call(world, save, random, data, path) @@ -9840,6 +9937,12 @@ func _gen1_settle( "ok": false, "reason": run["reason"], "details": run.get("details", ""), "passes": pass_index, "texts": texts, } + ## `jp Init`: the console restarts and no map script runs again. + if bool(run.get("soft_reset", false)): + return { + "ok": true, "passes": pass_index + 1, "statuses": statuses, + "battles": battles, "texts": texts, "warps": warps, "soft_reset": true, + } var stepping: bool = world.player_step_in_progress() ## A state that only writes the next one shows nothing on its own pass. var script_state: int = world.gen1_map_script_state() @@ -11436,6 +11539,191 @@ func _gen1_viridian_gym_leg( return _gen1_warp_legs(path, world, save, random, data, [[GEN1_VIRIDIAN_CITY, "viridian_gym_exit"]]) +## `TryPushingBoulder` [param count] times from [param approach], the player +## stepping after the boulder between pushes, since `MoveSprite` moves it alone. +func _gen1_push( + path: Array, step: String, world: Gen2WorldAPI, approach: Vector2i, direction: Vector2i, + count: int, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData +) -> Dictionary: + var walked: Dictionary = _gen1_walk(world, approach, save, random, data) + var stepped: Dictionary = _gen1_step(path, "%s_approach" % step, world, walked) + if not bool(stepped["ok"]): + return stepped + var pushes: Array = [] + for _push: int in count: + _settle_object_steps(world, random) + ## The first bump only arms `TryPushingBoulder`; the next press pushes. + var result: Dictionary = world.move_result(direction) + if not result.has("boulder_pushed"): + result = world.move_result(direction) + if not result.has("boulder_pushed"): + return _gen1_step(path, step, world, {"ok": false, "reason": "no boulder moved from %s: %s" % [ + _cell_value(world), result.get("reason", "the step succeeded")]}) + var settled: Dictionary = _gen1_settle(world, save, random, data, _dispatch_after_step(world)) + if not bool(settled.get("ok", false)): + return _gen1_step(path, step, world, settled) + pushes.append(_cell_value_from_vector((result["boulder_pushed"] as Dictionary)["to_cell"])) + _settle_object_steps(world, random) + if _push + 1 < count: + var followed: Dictionary = _gen1_walk(world, world.player_cell + direction, save, random, data) + if not bool(followed.get("ok", false)): + return _gen1_step(path, step, world, followed) + return _gen1_step(path, step, world, {"ok": true}, {"boulder": pushes}) + + +## STRENGTH on the LAPRAS, Route 22, its gate, and Route 23's guards and river +## to Victory Road's door. +func _gen1_route_23_leg( + world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, + path: Array +) -> Dictionary: + var taught: Dictionary = _teach_tm_hm(world, save, GEN1_HM04) + _mirror_party(world, save) + var stepped: Dictionary = _gen1_step(path, "viridian_teach_strength", world, taught, { + "moves": _party_moves(save)}) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + ["west", GEN1_ROUTE_22, "viridian_to_route_22"], + [GEN1_ROUTE_22_GATE, "route_22_gate", GEN1_ROUTE_22_GATE_DOOR], + [GEN1_ROUTE_23, "route_22_gate_north", GEN1_ROUTE_22_GATE_NORTH], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_step(path, "route_23_surf", world, _gen1_surf( + world, GEN1_ROUTE_23_SOUTH_BANK, Gen2WorldSprite.FACING_UP, save, random, data + )) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_step(path, "route_23_north_bank", world, _gen1_walk( + world, GEN1_ROUTE_23_NORTH_BANK, save, random, data + )) + if not bool(stepped["ok"]): + return stepped + return _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_VICTORY_ROAD_1F, "victory_road_entry", GEN1_ROUTE_23_VICTORY_ROAD_DOOR], + ]) + + +## Victory Road's three switches: 1F's boulder to the ladder, 2F's first to the +## east, 3F's into its hole and down onto 2F's second, then out to the Plateau. +func _gen1_victory_road_leg( + world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, + path: Array +) -> Dictionary: + var strength: Dictionary = world.strength_request() + if bool(strength.get("ok", false)): + strength = world.complete_strength() + var stepped: Dictionary = _gen1_step(path, "victory_road_strength", world, strength) + if not bool(stepped["ok"]): + return stepped + for push: Array in GEN1_VICTORY_ROAD_1F_PUSHES: + stepped = _gen1_push(path, String(push[0]), world, push[1], push[2], int(push[3]), save, random, data) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_flag_leg(path, "victory_road_1f_switch", world, + GEN1_EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH, "EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH") + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_VICTORY_ROAD_2F, "victory_road_1f_to_2f", GEN1_VICTORY_ROAD_1F_LADDER], + ]) + if not bool(stepped["ok"]): + return stepped + for push: Array in GEN1_VICTORY_ROAD_2F_PUSHES: + stepped = _gen1_push(path, String(push[0]), world, push[1], push[2], int(push[3]), save, random, data) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_flag_leg(path, "victory_road_2f_switch", world, + GEN1_EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1, "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1") + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_VICTORY_ROAD_3F, "victory_road_2f_to_3f", GEN1_VICTORY_ROAD_2F_LADDER], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_push(path, "victory_road_3f_push_gap", world, GEN1_VICTORY_ROAD_3F_GAP_ABOVE, + Vector2i.DOWN, 2, save, random, data) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_push(path, "victory_road_3f_push_into_hole", world, GEN1_VICTORY_ROAD_3F_HOLE_SIDE, + Vector2i.RIGHT, 1, save, random, data) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_VICTORY_ROAD_2F, "victory_road_3f_hole", GEN1_VICTORY_ROAD_3F_HOLE], + ]) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_push(path, "victory_road_2f_push_onto_second_switch", world, + GEN1_VICTORY_ROAD_2F_FALLEN_BOULDER_SIDE, Vector2i.LEFT, GEN1_VICTORY_ROAD_2F_SECOND_PUSHES, + save, random, data) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_flag_leg(path, "victory_road_2f_second_switch", world, + GEN1_EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2, "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2") + if not bool(stepped["ok"]): + return stepped + return _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_VICTORY_ROAD_3F, "victory_road_2f_to_3f_again", GEN1_VICTORY_ROAD_2F_EAST_LADDER], + [GEN1_VICTORY_ROAD_2F, "victory_road_3f_to_2f", GEN1_VICTORY_ROAD_3F_LADDER_DOWN], + [GEN1_ROUTE_23, "victory_road_exit", GEN1_VICTORY_ROAD_2F_EXIT], + ["north", GEN1_INDIGO_PLATEAU, "route_23_to_indigo_plateau"], + [GEN1_INDIGO_PLATEAU_LOBBY, "indigo_plateau_lobby", GEN1_INDIGO_PLATEAU_DOOR], + ]) + + +## The Elite Four's four rooms, the Champion and the Hall of Fame. +func _gen1_elite_four_leg( + world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData, + path: Array +) -> Dictionary: + var stepped: Dictionary = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_LORELEIS_ROOM, "loreleis_room", GEN1_LOBBY_STAIRS], + ]) + if not bool(stepped["ok"]): + return stepped + for room: Array in GEN1_ELITE_FOUR: + var member: Dictionary = _gen1_talk_to(world, GEN1_ELITE_MEMBER, save, random, data) + stepped = _gen1_step(path, "%s_fought" % room[0], world, member, {"party": _party_species(save)}) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_flag_leg(path, "%s_fought" % room[0], world, int(room[2]), String(room[3])) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [int(room[1]), "%s_next_door" % room[0], GEN1_ELITE_NEXT_DOOR], + ]) + if not bool(stepped["ok"]): + return stepped + var lance: Dictionary = _gen1_talk_to(world, GEN1_LANCE, save, random, data) + stepped = _gen1_step(path, "lance_fought", world, lance, {"party": _party_species(save)}) + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_flag_leg(path, "lance_fought", world, GEN1_EVENT_BEAT_LANCE, "EVENT_BEAT_LANCE") + if not bool(stepped["ok"]): + return stepped + stepped = _gen1_warp_legs(path, world, save, random, data, [ + [GEN1_CHAMPIONS_ROOM, "champions_room", GEN1_LANCES_ROOM_EXIT, GEN1_HALL_OF_FAME], + ]) + if not bool(stepped["ok"]): + return stepped + ## `ChampionsRoomOakAppearsScript` walks the player into the Hall, whose own + ## script inducts the party, clears every Plateau event, saves and resets, + ## all inside the one drain. + path.append({ + "step": "hall_of_fame", "map": _map_value(world), "cell": _cell_value(world), + "ok": world.map_id() == Vector2i(0, GEN1_HALL_OF_FAME) and world.state.hall_of_fame(), + "hall_of_fame_flag": world.state.hall_of_fame(), "party": _party_species(save), + "badges": world.state.badge_count(), + }) + if not bool(path[-1]["ok"]): + return {"ok": false, "path": path, "reason": "hall_of_fame: the walk ended on %s with the flag %s" % [ + _map_value(world), world.state.hall_of_fame()]} + return {"ok": true} + + ## The gym's pads to Sabrina, and the MARSHBADGE. func _gen1_saffron_gym_leg( world: Gen2WorldAPI, save: Gen2SaveData, random: RandomNumberGenerator, data: GameData,