import unittest from types import SimpleNamespace from worlds.alttp.EnemizerPatches import ( ARROW_REFILL_5_SPRITE_ID, BOSS_GFX_SHEET_INDEXES, BOSS_PATCH_DATA, DAMAGE_GROUP_TABLE_ADDRESS, DUNGEON_BOSS_PATCH_DATA, ENEMY_DAMAGE_TABLE_ADDRESS, ENEMY_HP_TABLE_ADDRESS, EXCLUDED_ENEMY_TABLE_SPRITE_IDS, HIDDEN_ENEMY_CHANCE_POOL_ADDRESS, RANDOMIZED_HIDDEN_ENEMY_CHANCE_POOL, RETRO_ARROW_REPLACEMENT_CHECK_ADDRESS, RETRO_RUPEE_REPLACEMENT_SPRITE_ID, THIEF_DEFAULT_HP, THIEF_SPRITE_ID, TILE_TRAP_FLOOR_TILE_ADDRESS, TRINEXX_ICE_FLOOR_ROUTINE_ADDRESS, TRINEXX_ICE_PROJECTILE_TILE_ADDRESS, VANILLA_HIDDEN_ENEMY_CHANCE_POOL, _apply_killable_thief, _apply_randomized_tile_trap_floor_tile, _get_enemizer_symbol, _make_native_enemizer_rng, _option_key, patch_bosses, _randomize_enemy_damage, _randomize_enemy_health, _set_enemizer_flag, _shuffle_damage_groups, _update_hidden_enemy_item_table_for_retro_mode, apply_enemizer_base_patch, ) class FakeRom: def __init__(self, size: int = 0x400000) -> None: self.buffer = bytearray(size) def read_byte(self, address: int) -> int: return self.buffer[address] def read_bytes(self, startaddress: int, length: int) -> bytearray: return self.buffer[startaddress:startaddress + length] def write_byte(self, address: int, value: int) -> None: self.buffer[address] = value def write_bytes(self, startaddress: int, values) -> None: self.buffer[startaddress:startaddress + len(values)] = values def write_int16(self, address: int, value: int) -> None: self.write_bytes(address, (value & 0xFF, (value >> 8) & 0xFF)) class TestEnemizerPatches(unittest.TestCase): def test_enemizer_base_patch_applies_mimic_hooks(self) -> None: rom = FakeRom() apply_enemizer_base_patch(rom) self.assertEqual(tuple(rom.read_bytes(0x307CB, 2)), (0xB6, 0x91)) self.assertEqual(tuple(rom.read_bytes(0x311B6, 4)), (0x22, 0x1A, 0x9A, 0x36)) self.assertEqual(tuple(rom.read_bytes(0x36C08, 5)), (0x22, 0x4E, 0x9A, 0x36, 0xEA)) self.assertEqual(tuple(rom.read_bytes(0x36DA6, 4)), (0x22, 0x66, 0x9A, 0x36)) self.assertEqual(tuple(rom.read_bytes(0xF0BB1, 2)), (0x95, 0xC7)) self.assertEqual(tuple(rom.read_bytes(TRINEXX_ICE_FLOOR_ROUTINE_ADDRESS, 4)), (0xEA, 0xEA, 0xEA, 0xEA)) self.assertEqual(tuple(rom.read_bytes(TRINEXX_ICE_PROJECTILE_TILE_ADDRESS, 2)), (0x00, 0x00)) self.assertEqual(rom.read_byte(TILE_TRAP_FLOOR_TILE_ADDRESS), 0x00) def test_randomized_tile_trap_floor_tile_patch_is_separate(self) -> None: rom = FakeRom() _apply_randomized_tile_trap_floor_tile(rom) self.assertEqual(tuple(rom.read_bytes(TRINEXX_ICE_PROJECTILE_TILE_ADDRESS, 2)), (0x88, 0x01)) self.assertEqual(rom.read_byte(TILE_TRAP_FLOOR_TILE_ADDRESS), 0x12) def test_enemy_shuffle_enables_hidden_enemy_and_mimic_support(self) -> None: rom = FakeRom() world = self._build_world(enemy_shuffle=True, bush_shuffle=False) self._apply_native_enemizer_features(world, rom) self.assertEqual( tuple(rom.read_bytes(HIDDEN_ENEMY_CHANCE_POOL_ADDRESS, len(VANILLA_HIDDEN_ENEMY_CHANCE_POOL))), VANILLA_HIDDEN_ENEMY_CHANCE_POOL, ) self.assertEqual(rom.read_byte(_get_enemizer_symbol("EnemizerFlags_randomize_bushes")), 0x01) self.assertEqual(rom.read_byte(_get_enemizer_symbol("EnemizerFlags_randomize_sprites")), 0x01) self.assertEqual(rom.read_byte(_get_enemizer_symbol("EnemizerFlags_enable_mimic_override")), 0x01) self.assertEqual(rom.read_byte(_get_enemizer_symbol("EnemizerFlags_enable_terrorpin_ai_fix")), 0x01) self.assertEqual(tuple(rom.read_bytes(0x1F2D5, 2)), (0x54, 0x9C)) self.assertEqual(rom.read_byte(0x1F2E5), 0xB0) self.assertEqual(rom.read_byte(0x1F2EB), 0xD0) def test_bush_shuffle_and_remaining_tables_are_patched_natively(self) -> None: rom = FakeRom() item_table_address = _get_enemizer_symbol("sprite_bush_spawn_item_table") not_item_sprite_address = _get_enemizer_symbol("notItemSprite_Mimic") rom.write_byte(RETRO_ARROW_REPLACEMENT_CHECK_ADDRESS, RETRO_RUPEE_REPLACEMENT_SPRITE_ID) rom.write_byte(item_table_address + 5, ARROW_REFILL_5_SPRITE_ID) rom.write_byte(ENEMY_HP_TABLE_ADDRESS + THIEF_SPRITE_ID, 0x08) included_hp_sprite_id = 0x01 included_damage_sprite_id = 0x02 excluded_sprite_id = min(EXCLUDED_ENEMY_TABLE_SPRITE_IDS) rom.write_byte(ENEMY_HP_TABLE_ADDRESS + included_hp_sprite_id, 0x06) rom.write_byte(ENEMY_HP_TABLE_ADDRESS + excluded_sprite_id, 0x07) rom.write_byte(ENEMY_DAMAGE_TABLE_ADDRESS + included_damage_sprite_id, 0x06) rom.write_byte(ENEMY_DAMAGE_TABLE_ADDRESS + excluded_sprite_id, 0x05) world = self._build_world( bush_shuffle=True, killable_thieves=True, enemy_health="hard", enemy_damage="chaos", ) self._apply_native_enemizer_features(world, rom) self.assertEqual( tuple(rom.read_bytes(HIDDEN_ENEMY_CHANCE_POOL_ADDRESS, len(RANDOMIZED_HIDDEN_ENEMY_CHANCE_POOL))), RANDOMIZED_HIDDEN_ENEMY_CHANCE_POOL, ) self.assertEqual(rom.read_byte(item_table_address + 5), RETRO_RUPEE_REPLACEMENT_SPRITE_ID) self.assertEqual(rom.read_byte(not_item_sprite_address + 4), THIEF_SPRITE_ID) self.assertNotEqual(rom.read_byte(ENEMY_HP_TABLE_ADDRESS + THIEF_SPRITE_ID), 0x08) self.assertGreaterEqual(rom.read_byte(ENEMY_HP_TABLE_ADDRESS + THIEF_SPRITE_ID), 2) self.assertLess(rom.read_byte(ENEMY_HP_TABLE_ADDRESS + THIEF_SPRITE_ID), 25) self.assertGreaterEqual(rom.read_byte(ENEMY_HP_TABLE_ADDRESS + included_hp_sprite_id), 2) self.assertLess(rom.read_byte(ENEMY_HP_TABLE_ADDRESS + included_hp_sprite_id), 25) self.assertEqual(rom.read_byte(ENEMY_HP_TABLE_ADDRESS + excluded_sprite_id), 0x07) self.assertIn(rom.read_byte(ENEMY_DAMAGE_TABLE_ADDRESS + included_damage_sprite_id), range(8)) self.assertEqual(rom.read_byte(ENEMY_DAMAGE_TABLE_ADDRESS + excluded_sprite_id), 0x05) for group_id in range(10): group_address = DAMAGE_GROUP_TABLE_ADDRESS + (group_id * 3) green_mail, blue_mail, red_mail = rom.read_bytes(group_address, 3) self.assertIn(green_mail, range(64)) self.assertIn(blue_mail, range(64)) self.assertIn(red_mail, range(64)) def test_killable_thief_sets_default_hp_without_enemy_health_shuffle(self) -> None: rom = FakeRom() rom.write_byte(ENEMY_HP_TABLE_ADDRESS + THIEF_SPRITE_ID, 0x08) world = self._build_world(killable_thieves=True) self._apply_native_enemizer_features(world, rom) self.assertEqual(rom.read_byte(ENEMY_HP_TABLE_ADDRESS + THIEF_SPRITE_ID), THIEF_DEFAULT_HP) def test_bush_shuffle_without_enemy_shuffle_does_not_enable_sprite_randomization_flags(self) -> None: rom = FakeRom() self._apply_native_enemizer_features(self._build_world(bush_shuffle=True), rom) self.assertEqual(rom.read_byte(_get_enemizer_symbol("EnemizerFlags_randomize_bushes")), 0x01) self.assertEqual(rom.read_byte(_get_enemizer_symbol("EnemizerFlags_randomize_sprites")), 0x00) self.assertEqual(rom.read_byte(_get_enemizer_symbol("EnemizerFlags_enable_mimic_override")), 0x00) self.assertEqual(rom.read_byte(_get_enemizer_symbol("EnemizerFlags_enable_terrorpin_ai_fix")), 0x00) self.assertEqual(tuple(rom.read_bytes(0x1F2D5, 2)), (0x00, 0x00)) self.assertEqual(rom.read_byte(0x1F2E5), 0x00) self.assertEqual(rom.read_byte(0x1F2EB), 0x00) def test_non_chaos_enemy_damage_uses_expected_mail_scaling(self) -> None: rom = FakeRom() self._apply_native_enemizer_features(self._build_world(enemy_damage="hard"), rom) for group_id in range(10): group_address = DAMAGE_GROUP_TABLE_ADDRESS + (group_id * 3) green_mail, blue_mail, red_mail = rom.read_bytes(group_address, 3) self.assertEqual(blue_mail, green_mail * 3 // 4) self.assertEqual(red_mail, green_mail * 3 // 8) def test_patch_bosses_overwrites_enemy_shuffle_boss_room_graphics(self) -> None: rom = FakeRom() dungeon_header_base = _get_enemizer_symbol("room_header_table") eastern_dungeon_data = DUNGEON_BOSS_PATCH_DATA[("Eastern Palace", None)] rom.write_byte(dungeon_header_base + (eastern_dungeon_data.room_id * 14) + 3, BOSS_PATCH_DATA["Armos"].graphics) for table_index in BOSS_GFX_SHEET_INDEXES.values(): rom.write_byte(0x4FC0 + table_index, 0xAA) rom.write_byte(0x509F + table_index, 0xBB) rom.write_byte(0x517E + table_index, 0xCC) patch_bosses(self._build_boss_world({"Eastern Palace": "Vitreous"}), rom) eastern_boss_data = BOSS_PATCH_DATA["Vitreous"] self.assertEqual( tuple(rom.read_bytes(eastern_dungeon_data.sprite_pointer_address, 2)), eastern_boss_data.pointer, ) self.assertEqual( rom.read_byte(dungeon_header_base + (eastern_dungeon_data.room_id * 14) + 3), eastern_boss_data.graphics, ) for table_index in BOSS_GFX_SHEET_INDEXES.values(): self.assertEqual(rom.read_byte(0x4FC0 + table_index), 0xAA) self.assertEqual(rom.read_byte(0x509F + table_index), 0xBB) self.assertEqual(rom.read_byte(0x517E + table_index), 0xCC) def test_native_enemizer_rng_is_deterministic_for_same_world_settings(self) -> None: world = self._build_world(enemy_health="hard", enemy_damage="chaos", bush_shuffle=True) rng_a = _make_native_enemizer_rng(world) rng_b = _make_native_enemizer_rng(world) self.assertEqual([rng_a.randrange(256) for _ in range(8)], [rng_b.randrange(256) for _ in range(8)]) @staticmethod def _apply_native_enemizer_features(world: SimpleNamespace, rom: FakeRom) -> None: enemy_shuffle_enabled = bool(world.options.enemy_shuffle) bush_shuffle_enabled = bool(world.options.bush_shuffle) enemy_health_key = _option_key(world.options.enemy_health) enemy_damage_key = _option_key(world.options.enemy_damage) if enemy_shuffle_enabled or bush_shuffle_enabled: _set_enemizer_flag(rom, "EnemizerFlags_randomize_bushes", True) hidden_enemy_chance_pool = ( RANDOMIZED_HIDDEN_ENEMY_CHANCE_POOL if bush_shuffle_enabled else VANILLA_HIDDEN_ENEMY_CHANCE_POOL ) rom.write_bytes(HIDDEN_ENEMY_CHANCE_POOL_ADDRESS, hidden_enemy_chance_pool) _update_hidden_enemy_item_table_for_retro_mode(rom) if enemy_shuffle_enabled: _set_enemizer_flag(rom, "EnemizerFlags_randomize_sprites", True) _set_enemizer_flag(rom, "EnemizerFlags_enable_mimic_override", True) _set_enemizer_flag(rom, "EnemizerFlags_enable_terrorpin_ai_fix", True) rom.write_bytes(0x1F2D5, (0x54, 0x9C)) rom.write_byte(0x1F2E5, 0xB0) rom.write_byte(0x1F2EB, 0xD0) if world.options.killable_thieves: _apply_killable_thief(rom) if enemy_health_key != "default" or enemy_damage_key != "default": rng = _make_native_enemizer_rng(world) else: rng = None if enemy_health_key != "default": assert rng is not None _randomize_enemy_health(rom, rng, enemy_health_key) if enemy_damage_key != "default": assert rng is not None _randomize_enemy_damage(rom, rng, allow_zero_damage=True) _shuffle_damage_groups(rom, rng, chaos_mode=enemy_damage_key == "chaos", allow_zero_damage=True) @staticmethod def _build_world( *, enemy_shuffle: bool = False, bush_shuffle: bool = False, killable_thieves: bool = False, enemy_health: str = "default", enemy_damage: str = "default", ) -> SimpleNamespace: return SimpleNamespace( player=1, multiworld=SimpleNamespace(seed=12345, seed_name="native-enemizer-test"), options=SimpleNamespace( enemy_shuffle=enemy_shuffle, bush_shuffle=bush_shuffle, killable_thieves=killable_thieves, enemy_health=SimpleNamespace(current_key=enemy_health), enemy_damage=SimpleNamespace(current_key=enemy_damage), ), ) @staticmethod def _build_boss_world(boss_overrides: dict[str, str] | None = None) -> SimpleNamespace: boss_overrides = boss_overrides or {} def boss(name: str) -> SimpleNamespace: return SimpleNamespace(enemizer_name=name) return SimpleNamespace( options=SimpleNamespace(mode="open"), dungeons={ "Eastern Palace": SimpleNamespace(boss=boss(boss_overrides.get("Eastern Palace", "Armos"))), "Desert Palace": SimpleNamespace(boss=boss(boss_overrides.get("Desert Palace", "Lanmola"))), "Tower of Hera": SimpleNamespace(boss=boss(boss_overrides.get("Tower of Hera", "Moldorm"))), "Palace of Darkness": SimpleNamespace(boss=boss(boss_overrides.get("Palace of Darkness", "Helmasaur"))), "Swamp Palace": SimpleNamespace(boss=boss(boss_overrides.get("Swamp Palace", "Arrghus"))), "Skull Woods": SimpleNamespace(boss=boss(boss_overrides.get("Skull Woods", "Mothula"))), "Thieves Town": SimpleNamespace(boss=boss(boss_overrides.get("Thieves Town", "Blind"))), "Ice Palace": SimpleNamespace(boss=boss(boss_overrides.get("Ice Palace", "Kholdstare"))), "Misery Mire": SimpleNamespace(boss=boss(boss_overrides.get("Misery Mire", "Vitreous"))), "Turtle Rock": SimpleNamespace(boss=boss(boss_overrides.get("Turtle Rock", "Trinexx"))), "Ganons Tower": SimpleNamespace( bosses={ "bottom": boss(boss_overrides.get("Ganons Tower Bottom", "Armos")), "middle": boss(boss_overrides.get("Ganons Tower Middle", "Lanmola")), "top": boss(boss_overrides.get("Ganons Tower Top", "Moldorm")), } ), }, ) if __name__ == "__main__": unittest.main()