mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-07-11 14:19:26 -07:00
Gauntlet Legends: Implement New Game (#3499)
* Update GauntletLegendsClient.py * Continuation of obelisk update * APPP Implementation Groundwork. * Continuation of APPP Implementation * Create Pain.asm * ROM verification modified since crc32 hash can no longer be calculated. * Initial upload of barrel/chest locations. * Finished adding and naming all chests/barrels. * Continued locations. * Added new yaml options * Work on mirror shards * Python 3.8 compatibility * Finalized location names. Fixed lambda bug. * v1.0 * Removed excess Docs and Changed item counts. * Update Archipelago Unittests.run.xml * Started working on Docs. * Updated item arrays for client * Fix client bugs * Flake8 fixes + Yaml fix * Update speed boots * v1.1 * Update GauntletLegendsClient.py * Update GauntletLegendsClient.py * Update GauntletLegendsClient.py * Update GauntletLegendsClient.py * v1.0.4 * Initial Ruff format * Formatting part-2 * More formatting + Doc changes * Updated Docs * Move client to root * Update Client Name * Make Imports Relative * Add new yaml options + rename obelisks * v1.1 Initial Upload * v1.1.0 + some additions * Client Update * Client Updates * pre-v1.1.1 * Update GauntletLegendsClient.py * pre-1.2 * 1.2.0 * v1.2.1 * Client fixes * v1.3.0 * v1.4.0 * v1.4.1 * Merged Changes * Experimental * pre-v1.5.0 * Changes * v1.5.0 * Update GauntletLegendsClient.py * Death Update * pre v1.5.1 * Obelisk rule changes * pre-v1.5.1 * Update GauntletLegendsClient.py * v1.5.1 * v1.5.1 * Update GauntletLegendsClient.py * Small fix * Update GauntletLegendsClient.py * v1.5.2 * pre-v1.6.0 * pre-v1.6.0 * v1.6.0 * Update * Update GauntletLegendsClient.py * Test * pre-v1.6.0 * Client Updtaes * Update GauntletLegendsClient.py * Update GauntletLegendsClient.py * Update GauntletLegendsClient.py * Update Rom.py * Update GauntletLegendsClient.py * Update Rom.py * Added Logging on stage enter * v1.6.2 * v1.6.2 * Update Rom.py * Update GauntletLegendsClient.py * v1.6.3 * Small Update * Update GauntletLegendsClient.py * Update GauntletLegendsClient.py * Update GauntletLegendsClient.py * Changes ready for review * Update Rom.py * Remove root client * Update inno_setup.iss * Update client * Initial Review Refactor * Update Arrays.py * Review refactor * Update __init__.py * Small tweaks after test generations * Update GauntletLegendsClient.py * Implement new check_locations() function * Add name for Skorn to level_names Dict * Fix for #4671 * Update __init__.py * Adjust item frequency + Place 50% of filler as local * Oops I modified the itempool after create_items teehee * Update __init__.py * Check MD5 hash before patching ROM * Update __init__.py * Fix get_filler_item_name() * Update MD5 check to built in method in FilePath * Update Rom.py * Update worlds/gl/Rom.py Co-authored-by: qwint <qwint.42@gmail.com> * WIP Generation fixes * v1.7.1 * Update Archipelago Unittests.run.xml * 0.7.2 * Update GauntletLegendsClient.py * Review Edits * Update World description * Fuzz fixes * Update GauntletLegendsClient.py * Make static data immutable * Update option comparisons since no longer a Choice * Update worlds/gl/Rom.py Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com> * Update __init__.py * Initial Update commit * Progress * Remove un-used import + Add manifest * Add missing docstring for client command * Progress * pre-v2.0.0-BETA * Updates * Progress * Working Generations * Working IN-Game * v2.0.0-BETA * Create archipelago.json * v2.0.1 * v2.0.1 FINAL * pre v2.0.2 * Delete report.json * Cleanup * pre v2.0.2 * v2.0.2 * PR Ready * Update Items.py * Delete fuzz.py * Update Items.py * Update GauntletLegendsClient.py * Fix scouting disabled locations * Client Updates * v2.0.3 * Increment World Version * Docstring + slot_data updates * Option name update + Remove redundant option * Code Cleanup * Trenches is level 24 * Frequencies * More cleanup and removal of stale code. MISSING BASEPATCH * v2.1.0 * Minor enemy changes * Small fix * Update Rom.py * v2.1.1 * Small fixes * Update basepatch.bsdiff4 * Update GauntletLegendsClient.py * Socket error clarity * Delete settings.local.json * Update GauntletLegendsClient.py * v2.1.2 fixes * Boss portal fix * Small client fix + Item Frequencies * Update archipelago.json * Fix for games playing with max difficulty lower than 4 not sending locations * Update archipelago.json * v2.1.5 * Fix for Chimera's lair mirror shard * Update archipelago.json * Mod Re-Base + Queue limits + Item Receiving fixes * Options cleanup and newlines for some files * Include v2.x features in docs * Make sure players has at least 1 item * v2.1.6 * Update archipelago.json * Review Edits + Client Tweaks --------- Co-authored-by: qwint <qwint.42@gmail.com> Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com> Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com> Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
This commit is contained in:
@@ -85,6 +85,7 @@ Currently, the following games are supported:
|
||||
* Satisfactory
|
||||
* EarthBound
|
||||
* Mega Man 3
|
||||
* Gauntlet Legends
|
||||
|
||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||
|
||||
@@ -80,6 +80,9 @@
|
||||
# Final Fantasy Mystic Quest
|
||||
/worlds/ffmq/ @Alchav @wildham0
|
||||
|
||||
# Gauntlet Legends
|
||||
/worlds/gl/ @jamesbrq
|
||||
|
||||
# Heretic
|
||||
/worlds/heretic/ @Daivuk @KScl
|
||||
|
||||
|
||||
@@ -206,11 +206,17 @@ Root: HKCR; Subkey: "{#MyAppName}ebpatch"; ValueData: "Archi
|
||||
Root: HKCR; Subkey: "{#MyAppName}ebpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ebpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
|
||||
Root: HKCR; Subkey: ".apmm3"; ValueData: "{#MyAppName}mm3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm3patch"; ValueData: "Archipelago Mega Man 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm3patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apgl"; ValueData: "{#MyAppName}glpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}glpatch"; ValueData: "Archipelago Gauntlet Legends Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}glpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}glpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
from collections.abc import Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from .Locations import LocationData, get_locations_by_tags
|
||||
|
||||
# Item name to ram value conversion
|
||||
item_ids: Final[Mapping[str, int]] = MappingProxyType({
|
||||
"Strength": 0x000002,
|
||||
"Speed": 0x000003,
|
||||
"Magic": 0x000004,
|
||||
"Armour": 0x000005,
|
||||
"Gold": 0x000006,
|
||||
"Key": 0x000007,
|
||||
"XP": 0x000009,
|
||||
"Level": 0x00000A,
|
||||
"Class": 0x00000B,
|
||||
"Max": 0x00000D,
|
||||
"Compass": 0x000270,
|
||||
"Lightning Potion": 0x001008,
|
||||
"Light Potion": 0x002008,
|
||||
"Acid Potion": 0x003008,
|
||||
"Fire Potion": 0x004008,
|
||||
"Acid Breath": 0x800480,
|
||||
"Lightning Breath": 0x8004F0,
|
||||
"Fire Breath": 0x800460,
|
||||
"Light Amulet": 0x400870,
|
||||
"Acid Amulet": 0x400880,
|
||||
"Lightning Amulet": 0x4008F0,
|
||||
"Fire Amulet": 0x400860,
|
||||
"Lightning Shield": 0x400CF0,
|
||||
"Fire Shield": 0x400C60,
|
||||
"Invisibility": 0x4000E0,
|
||||
"Levitate": 0x400030,
|
||||
"Speed Boots": 0x400173,
|
||||
"3-Way Shot": 0x400020,
|
||||
"5-Way Shot": 0x400160,
|
||||
"Rapid Fire": 0x400050,
|
||||
"Reflective Shot": 0x400840,
|
||||
"Reflective Shield": 0x400C40,
|
||||
"Super Shot": 0x8008B0,
|
||||
"Timestop": 0x4000D0,
|
||||
"Phoenix Familiar": 0x400110,
|
||||
"Growth": 0x400120,
|
||||
"Shrink": 0x400130,
|
||||
"Thunder Hammer": 0x800940,
|
||||
"Anti-Death Halo": 0x7000A0,
|
||||
"Invulnerability": 0x7000C0,
|
||||
"Health": 0x000001,
|
||||
"Runestone": 0x008210,
|
||||
"Mirror Shard": 0x008260,
|
||||
"Ice Axe of Untar": 0x8201C0,
|
||||
"Flame of Tarkana": 0x8201D0,
|
||||
"Scimitar of Decapitation": 0x8201E0,
|
||||
"Marker's Javelin": 0x8201F0,
|
||||
"Soul Savior": 0x820200,
|
||||
"Mountain": 0x00804A,
|
||||
"Castle": 0x00801A,
|
||||
"Hell": 0x00806A,
|
||||
"Ice": 0x00807A,
|
||||
"Town": 0x00808A,
|
||||
"Temple": 0x00809A,
|
||||
"Battlefield": 0x0080DA,
|
||||
"Skorne": 0x0080EA,
|
||||
"Secret": 0x0080FA,
|
||||
"Obelisk": 0x00800C,
|
||||
"Minotaur": 0x0001A1,
|
||||
"Falconess": 0x0001A2,
|
||||
"Jackal": 0x0001A3,
|
||||
"Tigress": 0x0001A4,
|
||||
"Sumner": 0x0001A5,
|
||||
"Skorne's Mask": 0x840620,
|
||||
"Skorne's Horns": 0x840630,
|
||||
"Skorne's Right Gauntlet": 0x840A40,
|
||||
"Skorne's Left Gauntlet": 0x840A50,
|
||||
"Death": 0x0000F0,
|
||||
"Crossbow Shooter": 0x0000F1,
|
||||
"Bomb Thrower": 0x0000F2,
|
||||
"Bomb Runner": 0x0000F3,
|
||||
"Golem": 0x0000F4,
|
||||
})
|
||||
|
||||
# Character names used for slot data
|
||||
characters: Final[tuple[str, ...]] = ("Minotaur", "Falconess", "Tigress", "Jackal", "Sumner")
|
||||
|
||||
# Base item charge count per pickup
|
||||
# Some items are bitwise
|
||||
base_count: Final[Mapping[str, int]] = MappingProxyType({
|
||||
"Key": 1,
|
||||
"Lightning Potion": 1,
|
||||
"Light Potion": 1,
|
||||
"Acid Potion": 1,
|
||||
"Fire Potion": 1,
|
||||
"Acid Breath": 5,
|
||||
"Lightning Breath": 5,
|
||||
"Fire Breath": 5,
|
||||
"Light Amulet": 30,
|
||||
"Acid Amulet": 30,
|
||||
"Lightning Amulet": 30,
|
||||
"Fire Amulet": 30,
|
||||
"Lightning Shield": 10,
|
||||
"Fire Shield": 10,
|
||||
"Invisibility": 30,
|
||||
"Levitate": 30,
|
||||
"Speed Boots": 30,
|
||||
"3-Way Shot": 30,
|
||||
"5-Way Shot": 30,
|
||||
"Rapid Fire": 30,
|
||||
"Reflective Shot": 30,
|
||||
"Reflective Shield": 30,
|
||||
"Super Shot": 3,
|
||||
"Timestop": 15,
|
||||
"Phoenix Familiar": 30,
|
||||
"Growth": 30,
|
||||
"Shrink": 30,
|
||||
"Thunder Hammer": 3,
|
||||
"Anti-Death Halo": 30,
|
||||
"Invulnerability": 30,
|
||||
"Fruit": 50,
|
||||
"Meat": 100,
|
||||
"Gold": 100,
|
||||
"Runestone 1": 1,
|
||||
"Runestone 2": 2,
|
||||
"Runestone 3": 3,
|
||||
"Runestone 4": 4,
|
||||
"Runestone 5": 5,
|
||||
"Runestone 6": 6,
|
||||
"Runestone 7": 7,
|
||||
"Runestone 8": 8,
|
||||
"Runestone 9": 9,
|
||||
"Runestone 10": 10,
|
||||
"Runestone 11": 11,
|
||||
"Runestone 12": 12,
|
||||
"Runestone 13": 13,
|
||||
"Dragon Mirror Shard": 1,
|
||||
"Chimera Mirror Shard": 3,
|
||||
"Plague Fiend Mirror Shard": 4,
|
||||
"Yeti Mirror Shard": 2,
|
||||
"Valley of Fire Obelisk": 1,
|
||||
"Dagger Peak Obelisk": 2,
|
||||
"Cliffs of Desolation Obelisk": 3,
|
||||
"Poisoned Fields Obelisk": 4,
|
||||
"Haunted Cemetery Obelisk": 5,
|
||||
"Castle Courtyard Obelisk": 6,
|
||||
"Dungeon of Torment Obelisk": 7,
|
||||
"Ice Axe of Untar": 1,
|
||||
"Flame of Tarkana": 1,
|
||||
"Scimitar of Decapitation": 1,
|
||||
"Marker's Javelin": 1,
|
||||
"Soul Savior": 1,
|
||||
"Minotaur": 1,
|
||||
"Falconess": 1,
|
||||
"Tigress": 1,
|
||||
"Jackal": 1,
|
||||
"Sumner": 1,
|
||||
"Poison Fruit": -50,
|
||||
"Skorne's Mask": 50,
|
||||
"Skorne's Horns": 50,
|
||||
"Skorne's Left Gauntlet": 100,
|
||||
"Skorne's Right Gauntlet": 100,
|
||||
"Portal to Dagger Peak": 1,
|
||||
"Portal to Cliffs of Desolation": 2,
|
||||
"Portal to Lost Cave": 3,
|
||||
"Portal to Volcanic Caverns": 4,
|
||||
"Portal to Dragon's Lair": 5,
|
||||
"Portal to Dungeon of Torment": 1,
|
||||
"Portal to Tower Armory": 2,
|
||||
"Portal to Castle Treasury": 3,
|
||||
"Portal to Chimera's Keep": 4,
|
||||
"Portal to Haunted Cemetery": 1,
|
||||
"Portal to Venomous Spire": 2,
|
||||
"Portal to Toxic Air Ship": 3,
|
||||
"Portal to Vat of the Plague Fiend": 4,
|
||||
"Portal to Frozen Camp": 1,
|
||||
"Portal to Crystal Mine": 2,
|
||||
"Portal to Erupting Fissure": 3,
|
||||
"Portal to Yeti's Cavern": 4,
|
||||
"Portal to Fortified Towers": 1,
|
||||
"Portal to Infernal Fortress": 2,
|
||||
"Death": 1,
|
||||
"Crossbow Shooter": 1,
|
||||
"Bomb Thrower": 1,
|
||||
"Bomb Runner": 1,
|
||||
"Golem": 1
|
||||
})
|
||||
|
||||
# (zone_config << 4) + room_config -> location list
|
||||
level_locations: Final[Mapping[int, Sequence[LocationData]]] = MappingProxyType({
|
||||
# Zone 0x00 - Castle
|
||||
0x00: get_locations_by_tags("castle_courtyard"),
|
||||
0x01: get_locations_by_tags("dungeon_of_torment"),
|
||||
0x02: get_locations_by_tags("tower_armory"),
|
||||
0x03: get_locations_by_tags("castle_treasury"),
|
||||
0x08: get_locations_by_tags("chimeras_keep"),
|
||||
|
||||
# Zone 0x03 - Mountain
|
||||
0x30: get_locations_by_tags("valley_of_fire"),
|
||||
0x31: get_locations_by_tags("dagger_peak"),
|
||||
0x32: get_locations_by_tags("cliffs_of_desolation"),
|
||||
0x33: get_locations_by_tags("lost_cave"),
|
||||
0x34: get_locations_by_tags("volcanic_cavern"),
|
||||
0x38: get_locations_by_tags("dragons_lair"),
|
||||
|
||||
# Zone 0x07 - Town
|
||||
0x70: get_locations_by_tags("poisoned_fields"),
|
||||
0x71: get_locations_by_tags("haunted_cemetery"),
|
||||
0x72: get_locations_by_tags("venomous_spire"),
|
||||
0x73: get_locations_by_tags("toxic_air_ship"),
|
||||
0x78: get_locations_by_tags("plague_fiend"),
|
||||
|
||||
# Zone 0x05 - Underworld
|
||||
0x50: get_locations_by_tags("gates_of_the_underworld"),
|
||||
|
||||
# Zone 0x06 - Ice
|
||||
0x60: get_locations_by_tags("arctic_docks"),
|
||||
0x61: get_locations_by_tags("frozen_camp"),
|
||||
0x62: get_locations_by_tags("crystal_mine"),
|
||||
0x63: get_locations_by_tags("erupting_fissure"),
|
||||
0x68: get_locations_by_tags("yeti"),
|
||||
|
||||
# Zone 0x0D - Temple
|
||||
0xD0: get_locations_by_tags("desecrated_temple"),
|
||||
0xD8: get_locations_by_tags("altar_of_skorne"),
|
||||
|
||||
# Zone 0x0C - Battlefield
|
||||
0xC0: get_locations_by_tags("battle_trenches"),
|
||||
0xC1: get_locations_by_tags("fortified_towers"),
|
||||
0xC2: get_locations_by_tags("infernal_fortress"),
|
||||
})
|
||||
|
||||
# Compressed level size in ROM
|
||||
level_size: Final[tuple[int, ...]] = (
|
||||
0x9E0,
|
||||
0x5E0,
|
||||
0x740,
|
||||
0x8A0,
|
||||
0x90,
|
||||
0x3B0,
|
||||
0x5A0,
|
||||
0x890,
|
||||
0x670,
|
||||
0x7D0,
|
||||
0x90,
|
||||
0xCE0,
|
||||
0xA50,
|
||||
0xA30,
|
||||
0x8E0,
|
||||
0x20,
|
||||
0x760,
|
||||
0xE90,
|
||||
0xE40,
|
||||
0xE00,
|
||||
0xCD0,
|
||||
0x20,
|
||||
0x3F0,
|
||||
0x0,
|
||||
0xB00,
|
||||
0xA30,
|
||||
0xB30
|
||||
)
|
||||
|
||||
# Level address in ROM
|
||||
level_address: Final[tuple[int, ...]] = (
|
||||
0xF939B0,
|
||||
0xF958B0,
|
||||
0xF945B0,
|
||||
0xF94EE0,
|
||||
0xF84CC0,
|
||||
0xF910B0,
|
||||
0xF915B0,
|
||||
0xF91D00,
|
||||
0xF92710,
|
||||
0xF92F40,
|
||||
0xF84B70,
|
||||
0xF84FA0,
|
||||
0xF85EB0,
|
||||
0xF86B60,
|
||||
0xF877C0,
|
||||
0xF880E0,
|
||||
0xF901C0,
|
||||
0xF884E0,
|
||||
0xF89370,
|
||||
0xF8A5F0,
|
||||
0xF8B760,
|
||||
0xF8C7C0,
|
||||
0xF90B50,
|
||||
0x0,
|
||||
0xF8D960,
|
||||
0xF8E6E0,
|
||||
0xF8F110
|
||||
)
|
||||
|
||||
# Level header address in ROM
|
||||
level_header: Final[tuple[int, ...]] = (
|
||||
0xF9DD9C,
|
||||
0xF9E07C,
|
||||
0xF9DE54,
|
||||
0xF9DF0C,
|
||||
0xF9DFC4,
|
||||
0xF9E6E0,
|
||||
0xF9E798,
|
||||
0xF9E850,
|
||||
0xF9E908,
|
||||
0xF9E9C0,
|
||||
0xF9EA78,
|
||||
0xF9F004,
|
||||
0xF9F0BC,
|
||||
0xF9F174,
|
||||
0xF9F22C,
|
||||
0xF9F2E4,
|
||||
0xF9E1C0,
|
||||
0xF9E330,
|
||||
0xF9E3E4,
|
||||
0xF9E498,
|
||||
0xF9E54C,
|
||||
0xF9E600,
|
||||
0xF9DC2C,
|
||||
0x0,
|
||||
0xF9DA04,
|
||||
0xF9DABC,
|
||||
0xF9DB74
|
||||
)
|
||||
|
||||
# Runestones required to access difficulties
|
||||
# Used in Rules.py for access calculation
|
||||
difficulty_lambda: Final[Mapping[int, Sequence[int]]] = MappingProxyType({
|
||||
0x3: (0, 2, 3, 4),
|
||||
0x0: (0, 5, 6, 7),
|
||||
0x7: (0, 8, 9, 10),
|
||||
0x6: (0, 11, 12, 13),
|
||||
0xD: (0, 14, 15, 16),
|
||||
0xC: (0, 17, 18, 19),
|
||||
0x5: (0, 20, 21, 22)
|
||||
})
|
||||
|
||||
# Runestones required to access difficulties
|
||||
# Used in Rules.py for access calculation
|
||||
difficulty_lambda_no_portal: Final[Mapping[int, Sequence[int]]] = MappingProxyType({
|
||||
0x3: (0, 0, 1, 2),
|
||||
0x0: (0, 2, 3, 4),
|
||||
0x7: (0, 5, 6, 7),
|
||||
0x6: (0, 8, 9, 10),
|
||||
0xD: (0, 11, 12, 13),
|
||||
0xC: (0, 14, 15, 16),
|
||||
0x5: (0, 17, 18, 19)
|
||||
})
|
||||
|
||||
# ID's for names said by announcer
|
||||
sounds: Final[Mapping[int, int]] = MappingProxyType({
|
||||
0: 0x9D,
|
||||
1: 0x9E,
|
||||
2: 0xA7,
|
||||
3: 0xA5,
|
||||
5: 0x9F,
|
||||
6: 0xA1,
|
||||
7: 0xA0,
|
||||
8: 0xA2,
|
||||
9: 0xA7
|
||||
})
|
||||
|
||||
# ID's for colors said by announcer
|
||||
colors: Final[Mapping[int, int]] = MappingProxyType({
|
||||
0: 0xA3,
|
||||
1: 0xA6,
|
||||
2: 0x9C,
|
||||
3: 0xA4
|
||||
})
|
||||
|
||||
obelisks: Final[tuple[str, ...]] = (
|
||||
"Valley of Fire Obelisk",
|
||||
"Dagger Peak Obelisk",
|
||||
"Cliffs of Desolation Obelisk",
|
||||
"Poisoned Fields Obelisk",
|
||||
"Haunted Cemetery Obelisk",
|
||||
"Castle Courtyard Obelisk",
|
||||
"Dungeon of Torment Obelisk"
|
||||
)
|
||||
|
||||
mirror_shards: Final[tuple[str, ...]] = (
|
||||
"Dragon Mirror Shard",
|
||||
"Chimera Mirror Shard",
|
||||
"Yeti Mirror Shard",
|
||||
"Plague Fiend Mirror Shard"
|
||||
)
|
||||
|
||||
portals: Final[Mapping[str, str]] = MappingProxyType({
|
||||
"Portal to Dagger Peak": "Mountain",
|
||||
"Portal to Cliffs of Desolation": "Mountain",
|
||||
"Portal to Lost Cave": "Mountain",
|
||||
"Portal to Volcanic Caverns": "Mountain",
|
||||
"Portal to Dragon's Lair": "Mountain",
|
||||
"Portal to Dungeon of Torment": "Castle",
|
||||
"Portal to Tower Armory": "Castle",
|
||||
"Portal to Castle Treasury": "Castle",
|
||||
"Portal to Chimera's Keep": "Castle",
|
||||
"Portal to Frozen Camp": "Ice",
|
||||
"Portal to Crystal Mine": "Ice",
|
||||
"Portal to Erupting Fissure": "Ice",
|
||||
"Portal to Yeti's Cavern": "Ice",
|
||||
"Portal to Haunted Cemetery": "Town",
|
||||
"Portal to Venomous Spire": "Town",
|
||||
"Portal to Toxic Air Ship": "Town",
|
||||
"Portal to Vat of the Plague Fiend": "Town",
|
||||
"Portal to Fortified Towers": "Battlefield",
|
||||
"Portal to Infernal Fortress": "Battlefield"
|
||||
})
|
||||
|
||||
excluded_portals: Final[Mapping[str, list[str]]] = MappingProxyType({
|
||||
"Castle" : ["Portal to Dungeon of Torment", "Portal to Tower Armory", "Portal to Castle Treasury", "Portal to Chimera's Keep"],
|
||||
"Town" : ["Portal to Haunted Cemetery", "Portal to Venomous Spire", "Portal to Toxic Air Ship", "Portal to Vat of the Plague Fiend"],
|
||||
"Ice" : ["Portal to Frozen Camp", "Portal to Crystal Mine", "Portal to Erupting Fissure", "Portal to Yeti's Cavern"],
|
||||
"Battlefield" : ["Portal to Fortified Towers", "Portal to Infernal Fortress"]
|
||||
})
|
||||
|
||||
excluded_levels: Final[Mapping[str, list[str]]] = MappingProxyType({
|
||||
"Mountain" : ["Valley of Fire", "Dagger Peak", "Cliffs of Desolation", "Lost Cave", "Volcanic Caverns", "Dragon's Lair"],
|
||||
"Castle" : ["Castle Courtyard" ,"Dungeon of Torment", "Tower Armory", "Castle Treasury", "Chimera's Keep"],
|
||||
"Ice" : ["Arctic Docks", "Frozen Camp", "Crystal Mine", "Erupting Fissure", "Yeti's Cavern"],
|
||||
"Town" : ["Poisoned Fields", "Haunted Cemetery", "Venomous Spire", "Toxic Air Ship", "Vat of the Plague Fiend"],
|
||||
"Battlefield" : ["Battle Trenches", "Fortified Towers", "Infernal Fortress"]
|
||||
})
|
||||
|
||||
excluded_obelisks: Final[Mapping[str, list[str]]] = MappingProxyType({
|
||||
"Castle" : ["Valley of Fire Obelisk", "Dagger Peak Obelisk", "Cliffs of Desolation Obelisk"],
|
||||
"Town" : ["Castle Courtyard Obelisk", "Dungeon of Torment Obelisk"],
|
||||
"Ice" : ["Poisoned Fields Obelisk", "Haunted Cemetery Obelisk"],
|
||||
})
|
||||
|
||||
# Map location names to offsets in decompressed boss.bin
|
||||
boss_location_offsets: Final[Mapping[str, int]] = MappingProxyType({
|
||||
"Dragon's Lair - Dragon Mirror Shard": 0x9C08,
|
||||
"Yeti's Cavern - Yeti Mirror Shard": 0x9C18,
|
||||
"Chimera's Keep - Chimera Mirror Shard": 0x9C28,
|
||||
"Vat of the Plague Fiend - Plague Fiend Mirror Shard": 0x9C38,
|
||||
"Altar of Skorne - Skorne's Mask": 0x9C48,
|
||||
"Altar of Skorne - Skorne's Horns": 0x9C58,
|
||||
"Altar of Skorne - Skorne's Left Gauntlet": 0x9C68,
|
||||
"Altar of Skorne - Skorne's Right Gauntlet": 0x9C78
|
||||
})
|
||||
|
||||
boss_regions: Final[tuple[str, ...]] = (
|
||||
"Dragon's Lair",
|
||||
"Yeti's Cavern",
|
||||
"Chimera's Keep",
|
||||
"Vat of the Plague Fiend",
|
||||
"Altar of Skorne",
|
||||
"Gates of the Underworld",
|
||||
)
|
||||
|
||||
|
||||
# Item IDs for spawner traps (77780087-77780090)
|
||||
spawner_trap_ids: Final[tuple[int, ...]] = (77780087, 77780088, 77780089, 77780090)
|
||||
|
||||
player_compass_index: Final[Mapping[int, int]] = MappingProxyType({
|
||||
1: 1,
|
||||
2: 0,
|
||||
3: 3,
|
||||
4: 2
|
||||
})
|
||||
@@ -0,0 +1,728 @@
|
||||
import asyncio
|
||||
import Utils
|
||||
import settings
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import traceback
|
||||
import subprocess
|
||||
import Patch
|
||||
|
||||
from typing import Optional
|
||||
from BaseClasses import ItemClassification
|
||||
from CommonClient import ClientCommandProcessor, CommonContext, get_base_parser, gui_enabled, logger, server_loop
|
||||
from NetUtils import ClientStatus, NetworkItem
|
||||
|
||||
from .Data import (
|
||||
base_count,
|
||||
item_ids,
|
||||
level_locations,
|
||||
sounds,
|
||||
colors,
|
||||
portals,
|
||||
spawner_trap_ids,
|
||||
player_compass_index
|
||||
)
|
||||
from .Items import ItemData, items_by_id
|
||||
|
||||
READ = "READ_CORE_RAM"
|
||||
WRITE = "WRITE_CORE_RAM"
|
||||
PLAYER_CLASS = 0xFD30F
|
||||
PLAYER_COLOR = 0xFD30E
|
||||
SOUND_ADDRESS = 0xAE740
|
||||
SOUND_START = 0xEEFC
|
||||
PLAYER_KILL = 0xFD300
|
||||
PLAYER_MENU = 0x9D3C
|
||||
BOSS_GOAL = 0x45D34
|
||||
BOSS_GOAL_BACKUP = 0x45D3C
|
||||
LOCATIONS_BASE_ADDRESS = 0x64A68
|
||||
ZONE_ID = 0x6CA58
|
||||
LEVEL_ID = 0x6CA5C
|
||||
|
||||
MOD_ITEM_ID = 0x3FC800
|
||||
MOD_QUANTITY = 0x3FC804
|
||||
MOD_PLAYER_ID = 0x3FC808
|
||||
MOD_OBELISK_QUANTITY = 0x3FC7E4
|
||||
MOD_BOSS_GOAL = 0x3FC7E5
|
||||
MOD_PLAYERS_LIST = 0x3FC7D0
|
||||
MOD_COMPASS_COUNT = 0x3FC7D4
|
||||
|
||||
GOAL_POLLS = 5
|
||||
|
||||
|
||||
class RetroSocket:
|
||||
def __init__(self):
|
||||
self.host = "localhost"
|
||||
self.port = 55355
|
||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.socket.setblocking(False)
|
||||
|
||||
def send(self, message: str):
|
||||
try:
|
||||
self.socket.sendto(message.encode(), (self.host, self.port))
|
||||
except Exception as e:
|
||||
raise Exception("An error occurred while sending a message.")
|
||||
|
||||
async def read(self, message: str) -> Optional[bytes]:
|
||||
self.send(message)
|
||||
try:
|
||||
response = await asyncio.wait_for(asyncio.get_event_loop().sock_recv(self.socket, 30000), 1.0)
|
||||
data = response.decode().strip("\n").split(" ")
|
||||
b = b""
|
||||
for s in data[2:]:
|
||||
if "-1" in s:
|
||||
raise Exception("Client tried to read from an invalid address or ROM is not open...")
|
||||
b += bytes.fromhex(s)
|
||||
return b
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Timeout while waiting for socket response...")
|
||||
await asyncio.sleep(2)
|
||||
return None
|
||||
except ConnectionResetError:
|
||||
logger.error("The connection was reset...")
|
||||
await asyncio.sleep(2)
|
||||
return None
|
||||
except OSError as e:
|
||||
logger.error(f"Socket error during read: {e}")
|
||||
await asyncio.sleep(2)
|
||||
return None
|
||||
|
||||
async def status(self) -> str:
|
||||
message = "GET_STATUS"
|
||||
self.send(message)
|
||||
try:
|
||||
data = await asyncio.wait_for(asyncio.get_event_loop().sock_recv(self.socket, 4096), 1.0)
|
||||
return data.decode()
|
||||
except (asyncio.TimeoutError, ConnectionResetError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def message_format(arg: str, params: str) -> str:
|
||||
return f"{arg} {params}"
|
||||
|
||||
|
||||
def param_format(adr: int, arr: bytes) -> str:
|
||||
return " ".join([hex(adr)] + [f"0x{byte:02X}" for byte in arr])
|
||||
|
||||
|
||||
class GauntletLegendsCommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_deathlink_toggle(self):
|
||||
"""Toggle Deathlink on or off"""
|
||||
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
|
||||
self.ctx.update_death_link(self.ctx.deathlink_enabled)
|
||||
logger.info(f"Deathlink {('Enabled.' if self.ctx.deathlink_enabled else 'Disabled.')}")
|
||||
|
||||
|
||||
class GauntletLegendsContext(CommonContext):
|
||||
command_processor = GauntletLegendsCommandProcessor
|
||||
game = "Gauntlet Legends"
|
||||
items_handling = 0b101
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.useful: list[NetworkItem] = []
|
||||
self.deathlink_pending: bool = False
|
||||
self.deathlink_enabled: bool = False
|
||||
self.deathlink_triggered: bool = False
|
||||
self.gl_sync_task = None
|
||||
self.glslotdata = None
|
||||
self.socket = RetroSocket()
|
||||
self.rom_loaded: bool = False
|
||||
self.locations_checked: list[int] = []
|
||||
self.retro_connected: bool = False
|
||||
self.scouted: bool = False
|
||||
self.obelisks: list[NetworkItem] = []
|
||||
self.item_locations: list[int] = []
|
||||
self.obelisk_locations: list[int] = []
|
||||
self.chest_locations: list[int] = []
|
||||
self.spawner_locations: list[int] = []
|
||||
self.item_address: int = 0
|
||||
self.chest_address: int = 0
|
||||
self.spawner_address: int = 0
|
||||
self.vanilla_spawner_count: int = 0
|
||||
self.zone: int = 0
|
||||
self.level: int = 0
|
||||
self.current_zone: int = -1
|
||||
self.current_level: int = -1
|
||||
self.level_id: int = 0
|
||||
self.location_scouts: list[NetworkItem] = []
|
||||
self.players: list[int] = []
|
||||
self.queued_traps: list[tuple[str, int, bool]] = []
|
||||
self.spawned_traps: int = 0
|
||||
self.item_ram_indices: dict[int, int] = {}
|
||||
self.chest_ram_indices: dict[int, int] = {}
|
||||
self.goal_streak: int = 0
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
super().on_deathlink(data)
|
||||
self.deathlink_pending = True
|
||||
|
||||
async def update_stage(self):
|
||||
self.zone = await self._read_ram_int(ZONE_ID, 1)
|
||||
self.level = await self._read_ram_int(LEVEL_ID, 1)
|
||||
|
||||
async def check_goal(self) -> bool:
|
||||
if self.glslotdata is None:
|
||||
return False
|
||||
if self.glslotdata["goal"] == 1:
|
||||
goal = await self._read_ram_int(BOSS_GOAL, 4)
|
||||
backup = await self._read_ram_int(BOSS_GOAL_BACKUP, 4)
|
||||
return goal == 0xA or backup == 0xA
|
||||
elif self.glslotdata["goal"] == 2:
|
||||
goal = await self._read_ram_int(MOD_BOSS_GOAL, 1, True)
|
||||
if goal >= self.glslotdata["boss_goal_count"]:
|
||||
self.goal_streak += 1
|
||||
else:
|
||||
self.goal_streak = 0
|
||||
return self.goal_streak >= GOAL_POLLS
|
||||
|
||||
def _normalize_item_name(self, name: str) -> str:
|
||||
if "Runestone" in name:
|
||||
return "Runestone"
|
||||
if "Fruit" in name or "Meat" in name:
|
||||
return "Health"
|
||||
if "Obelisk" in name:
|
||||
return "Obelisk"
|
||||
if "Mirror" in name:
|
||||
return "Mirror Shard"
|
||||
if portals.get(name, False):
|
||||
return portals[name]
|
||||
return name
|
||||
|
||||
async def update_item(self, name: str, count: int, player: int = None, infinite_count: bool = False):
|
||||
name = self._normalize_item_name(name)
|
||||
await self._write_ram(MOD_ITEM_ID,
|
||||
int.to_bytes((item_ids[name] if not infinite_count else item_ids[name] & 0xFFFF), 4,
|
||||
"little"))
|
||||
await self._write_ram(MOD_QUANTITY, int.to_bytes(count, 4, "little", signed=True))
|
||||
await self._write_ram(MOD_PLAYER_ID, int.to_bytes(player, 4, "little"))
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(GauntletLegendsContext, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected"}:
|
||||
self.glslotdata = args["slot_data"]
|
||||
self.deathlink_enabled = bool(self.glslotdata["death_link"])
|
||||
if self.deathlink_enabled:
|
||||
Utils.async_start(self.update_death_link(self.deathlink_enabled))
|
||||
elif cmd == "LocationInfo":
|
||||
self.location_scouts = args["locations"]
|
||||
elif cmd == "RoomInfo":
|
||||
self.seed_name = args["seed_name"]
|
||||
|
||||
# Update inventory based on items received from server
|
||||
# Also adds starting items based on a few yaml options
|
||||
async def handle_items(self):
|
||||
self.players = list(await self._read_ram(MOD_PLAYERS_LIST, 4))
|
||||
self.players = [player for player in self.players if player != 0]
|
||||
if not self.players:
|
||||
return
|
||||
for player in self.players:
|
||||
active = await self._read_ram_int(PLAYER_KILL + (0x1F0 * (player - 1)), 1)
|
||||
if active != 0x4:
|
||||
continue
|
||||
compass = await self._read_ram_int(MOD_COMPASS_COUNT + (2 * player_compass_index[player]), 2)
|
||||
if compass - 1 < len(self.items_received):
|
||||
for index in range(compass - 1, len(self.items_received)):
|
||||
active = await self._read_ram_int(PLAYER_KILL + (0x1F0 * (player - 1)), 1)
|
||||
if active != 0x4:
|
||||
break
|
||||
item = self.items_received[index].item
|
||||
if player != self.players[0] and item in spawner_trap_ids:
|
||||
continue
|
||||
item_name = items_by_id[item].item_name
|
||||
if self.current_zone in (0x8, 0xE) and item in spawner_trap_ids:
|
||||
if len([trap for trap in self.queued_traps if trap[1] == index]) < 1:
|
||||
self.queued_traps.append((item_name, index, False))
|
||||
await self.give_item(item_name, player)
|
||||
await self.update_item("Compass", 1, player)
|
||||
|
||||
async def give_item(self, item_name: str, player: int):
|
||||
await self.wait_for_mod_clear()
|
||||
await asyncio.sleep(0.02)
|
||||
await self.update_item(item_name, base_count[item_name], player)
|
||||
await self.wait_for_mod_clear()
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
async def wait_for_mod_clear(self, poll_interval: float = 0.05):
|
||||
while True:
|
||||
mod_data = await self.socket.read(message_format(READ, f"0x{MOD_ITEM_ID:x} 12"))
|
||||
if mod_data and all(byte == 0 for byte in mod_data):
|
||||
return True
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
async def _read_ram(self, address: int, size: int) -> bytes:
|
||||
return await self.socket.read(message_format(READ, f"0x{address:x} {size}"))
|
||||
|
||||
async def _read_ram_int(self, address: int, size: int, signed: bool = False) -> int:
|
||||
data = await self._read_ram(address, size)
|
||||
return int.from_bytes(data, "little", signed=signed) if data else 0
|
||||
|
||||
async def _write_ram(self, address: int, data: bytes):
|
||||
self.socket.send(message_format(WRITE, param_format(address, data)))
|
||||
|
||||
async def dead(self) -> bool:
|
||||
val = await self._read_ram_int(PLAYER_KILL + (0x1F0 * (self.players[0] - 1)), 1)
|
||||
return (val & 0xF) == 0x8
|
||||
|
||||
async def dead_or_menu(self) -> bool:
|
||||
val = await self._read_ram_int(PLAYER_KILL + (0x1F0 * (self.players[0] - 1)), 1)
|
||||
return (val & 0xF) == 0x8 or (val & 0xF) == 0x1
|
||||
|
||||
async def get_seed_name(self) -> str:
|
||||
seed_name = await self._read_ram(0x3FC7F0, 0x10)
|
||||
return seed_name.decode("utf-8").strip()
|
||||
|
||||
async def scout_locations(self, ctx: "GauntletLegendsContext") -> None:
|
||||
try:
|
||||
self.location_scouts = []
|
||||
self.obelisk_locations = []
|
||||
self.item_locations = []
|
||||
self.chest_locations = []
|
||||
self.spawner_locations = []
|
||||
self.useful = []
|
||||
self.obelisks = []
|
||||
self.vanilla_spawner_count = 0
|
||||
self.item_ram_indices = {}
|
||||
self.chest_ram_indices = {}
|
||||
|
||||
raw_locations = [location for location in level_locations.get(self.level_id, []) if
|
||||
"Mirror" not in location.name and "Skorne" not in location.name]
|
||||
scoutable_location_ids = [location.id for location in raw_locations if
|
||||
location.id in ctx.checked_locations or location.id in self.missing_locations]
|
||||
|
||||
# Scout locations if any exist
|
||||
if raw_locations:
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "LocationScouts",
|
||||
"locations": scoutable_location_ids,
|
||||
"create_as_hint": 0,
|
||||
}])
|
||||
while not self.location_scouts:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Build lookup for scouted items by location
|
||||
scouted_by_location = {item.location: item for item in self.location_scouts}
|
||||
|
||||
item_slots: list[int] = []
|
||||
chest_slots: list[int] = []
|
||||
appended_items: list[int] = []
|
||||
|
||||
for loc in raw_locations:
|
||||
is_obelisk_loc = "Obelisk" in loc.name
|
||||
is_chest_loc = (not is_obelisk_loc) and (
|
||||
"Chest" in loc.name
|
||||
or ("Barrel" in loc.name and "Barrel of Gold" not in loc.name)
|
||||
)
|
||||
|
||||
scouted = scouted_by_location.get(loc.id)
|
||||
|
||||
if not scouted:
|
||||
if is_obelisk_loc:
|
||||
pass
|
||||
elif is_chest_loc:
|
||||
chest_slots.append(loc.id)
|
||||
else:
|
||||
item_slots.append(loc.id)
|
||||
continue
|
||||
|
||||
item_id = scouted.item
|
||||
item_player = scouted.player
|
||||
item_data = items_by_id.get(item_id, ItemData())
|
||||
|
||||
if is_obelisk_loc:
|
||||
if "Obelisk" in item_data.item_name and item_player == self.slot:
|
||||
|
||||
pass
|
||||
else:
|
||||
|
||||
appended_items.append(loc.id)
|
||||
continue
|
||||
|
||||
if item_player == self.slot and item_id in spawner_trap_ids:
|
||||
continue
|
||||
|
||||
if item_player != self.slot:
|
||||
|
||||
if is_chest_loc:
|
||||
chest_slots.append(loc.id)
|
||||
else:
|
||||
item_slots.append(loc.id)
|
||||
continue
|
||||
|
||||
if "Obelisk" in item_data.item_name:
|
||||
continue
|
||||
|
||||
if (item_data.progression in (ItemClassification.useful, ItemClassification.progression)
|
||||
and is_chest_loc):
|
||||
appended_items.append(loc.id)
|
||||
continue
|
||||
|
||||
if is_chest_loc:
|
||||
chest_slots.append(loc.id)
|
||||
else:
|
||||
item_slots.append(loc.id)
|
||||
|
||||
final_item_order = item_slots + appended_items
|
||||
|
||||
self.item_ram_indices = {
|
||||
loc_id: idx for idx, loc_id in enumerate(final_item_order)
|
||||
}
|
||||
self.chest_ram_indices = {
|
||||
loc_id: idx for idx, loc_id in enumerate(chest_slots)
|
||||
}
|
||||
|
||||
# Categorize scouted locations - mirror ROM's patch_items logic exactly
|
||||
for loc in raw_locations:
|
||||
scouted_item = scouted_by_location.get(loc.id)
|
||||
if not scouted_item:
|
||||
continue
|
||||
|
||||
item_id = scouted_item.item
|
||||
item_player = scouted_item.player
|
||||
item_data = items_by_id.get(item_id, ItemData())
|
||||
is_chest = "Chest" in loc.name or ("Barrel" in loc.name and "Barrel of Gold" not in loc.name)
|
||||
|
||||
# Check obelisk locations
|
||||
if "Obelisk" in loc.name:
|
||||
if "Obelisk" in item_data.item_name and item_player == self.slot:
|
||||
self.obelisk_locations.append(loc.id)
|
||||
self.obelisks.append(scouted_item)
|
||||
else:
|
||||
self.item_locations.append(loc.id)
|
||||
continue
|
||||
|
||||
# Check spawner (ROM: item[1] == player and item[0] in SPAWNER_TRAP_IDS)
|
||||
if item_player == self.slot and item_id in spawner_trap_ids:
|
||||
self.spawner_locations.append(loc.id)
|
||||
continue
|
||||
|
||||
# Check non-local player (ROM: item[1] != player) - stays as item/chest
|
||||
if item_player != self.slot:
|
||||
if is_chest:
|
||||
self.chest_locations.append(loc.id)
|
||||
else:
|
||||
self.item_locations.append(loc.id)
|
||||
continue
|
||||
|
||||
# Check obelisk item at non-obelisk location (ROM: "Obelisk" in item_name)
|
||||
if "Obelisk" in item_data.item_name:
|
||||
self.obelisk_locations.append(loc.id)
|
||||
self.obelisks.append(scouted_item)
|
||||
continue
|
||||
|
||||
# Check useful/progression chest -> item conversion
|
||||
if item_data.progression in (ItemClassification.useful, ItemClassification.progression) and is_chest:
|
||||
self.item_locations.append(loc.id)
|
||||
self.useful.append(scouted_item)
|
||||
continue
|
||||
|
||||
# Regular item/chest
|
||||
if is_chest:
|
||||
self.chest_locations.append(loc.id)
|
||||
else:
|
||||
self.item_locations.append(loc.id)
|
||||
|
||||
self.scouted = True
|
||||
except Exception:
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
async def location_loop(self) -> list[int]:
|
||||
if self.current_zone == -1:
|
||||
self.current_zone = self.zone
|
||||
self.current_level = self.level
|
||||
self.level_id = (self.current_zone << 4) + self.current_level
|
||||
|
||||
zone_or_level_changed = self.zone != self.current_zone or self.level != self.current_level
|
||||
if zone_or_level_changed:
|
||||
if self.current_level & 0x8 == 0x8 and self.level_id != 0x58 and not await self.dead_or_menu():
|
||||
await self.check_locations([loc.id for loc in level_locations[self.level_id]
|
||||
if "Mirror Shard" in loc.name or "Skorne" in loc.name])
|
||||
self.current_zone = self.zone
|
||||
self.current_level = self.level
|
||||
self.level_id = (self.current_zone << 4) + self.current_level
|
||||
self.scouted = False
|
||||
self.spawned_traps = 0
|
||||
self.goal_streak = 0
|
||||
await asyncio.sleep(2)
|
||||
|
||||
if self.current_zone in (0x8, 0xE):
|
||||
return []
|
||||
|
||||
if not self.scouted:
|
||||
await self.scout_locations(self)
|
||||
|
||||
active = await self._read_ram_int(PLAYER_KILL + (0x1F0 * (self.players[0] - 1)), 1)
|
||||
if active != 0x4:
|
||||
return []
|
||||
|
||||
if len(self.queued_traps) > 0 and self.spawned_traps == 0:
|
||||
for i, trap in enumerate(self.queued_traps):
|
||||
trap_name, index, given = trap
|
||||
if not given:
|
||||
self.spawned_traps += 1
|
||||
await self.give_item(trap_name, self.players[0])
|
||||
self.queued_traps[i] = (trap_name, index, True)
|
||||
if self.spawned_traps >= 5:
|
||||
break
|
||||
|
||||
locations_address = await self._read_ram_int(LOCATIONS_BASE_ADDRESS, 4) & 0xFFFFFF
|
||||
self.item_address = await self._read_ram_int(locations_address + 0x14, 4)
|
||||
self.spawner_address = await self._read_ram_int(locations_address + 0x1C, 4)
|
||||
self.chest_address = await self._read_ram_int(locations_address + 0x30, 4)
|
||||
|
||||
# Read vanilla spawner count from level header (2 bytes at offset 0x28)
|
||||
spawner_count_data = await self._read_ram(locations_address + 0x6, 2)
|
||||
if spawner_count_data and not self.vanilla_spawner_count:
|
||||
total_spawner_count = int.from_bytes(spawner_count_data, "little")
|
||||
# Vanilla count is total minus the ones we added
|
||||
self.vanilla_spawner_count = total_spawner_count - len(self.spawner_locations)
|
||||
|
||||
if 0x7FFF0BAD in (self.item_address, self.chest_address, self.spawner_address):
|
||||
return []
|
||||
|
||||
self.item_address &= 0xFFFFFF
|
||||
self.spawner_address &= 0xFFFFFF
|
||||
self.chest_address &= 0xFFFFFF
|
||||
|
||||
acquired = []
|
||||
|
||||
max_item_idx = max((self.item_ram_indices[loc_id] for loc_id in self.item_locations), default=-1)
|
||||
item_section = await self._read_ram(self.item_address, (max_item_idx + 1) * 0x18) if max_item_idx >= 0 else b""
|
||||
for loc_id in self.item_locations:
|
||||
offset = self.item_ram_indices[loc_id] * 0x18
|
||||
active_byte, state = item_section[offset + 0x2], item_section[offset + 0x3]
|
||||
if state < 0x7F and active_byte == 1 and state == 0:
|
||||
acquired.append(loc_id)
|
||||
|
||||
obelisk = await self._read_ram_int(MOD_OBELISK_QUANTITY, 1)
|
||||
for j, loc_id in enumerate(self.obelisk_locations):
|
||||
bit = base_count[items_by_id[self.obelisks[j].item].item_name] - 1
|
||||
if obelisk & (1 << bit):
|
||||
acquired.append(loc_id)
|
||||
|
||||
max_chest_idx = max((self.chest_ram_indices[loc_id] for loc_id in self.chest_locations), default=-1)
|
||||
chest_section = await self._read_ram(self.chest_address, (max_chest_idx + 1) * 0x18) if max_chest_idx >= 0 else b""
|
||||
for loc_id in self.chest_locations:
|
||||
offset = self.chest_ram_indices[loc_id] * 0x18
|
||||
active_byte, state = chest_section[offset + 0x2], chest_section[offset + 0x3]
|
||||
if state < 0x7F and active_byte == 1 and state != 1:
|
||||
acquired.append(loc_id)
|
||||
|
||||
# Check spawner locations - these are added after vanilla spawners
|
||||
# Read spawners starting after vanilla_spawner_count
|
||||
if self.spawner_locations:
|
||||
spawner_start = self.spawner_address + (self.vanilla_spawner_count * 0x1C)
|
||||
spawner_section = await self._read_ram(spawner_start, len(self.spawner_locations) * 0x1C)
|
||||
for i, loc_id in enumerate(self.spawner_locations):
|
||||
offset = i * 0x1C
|
||||
active_byte, hit = spawner_section[offset + 0x2], spawner_section[offset + 0x1A]
|
||||
if active_byte == 1 and hit == 1:
|
||||
acquired.append(loc_id)
|
||||
|
||||
await self.update_stage()
|
||||
if self.zone != self.current_zone or self.level != self.current_level:
|
||||
return []
|
||||
return [loc_id for loc_id in acquired if loc_id not in self.checked_locations]
|
||||
|
||||
async def die(self):
|
||||
"""Trigger deathlink death with character-specific death sound."""
|
||||
self.deathlink_triggered = True
|
||||
for player in self.players:
|
||||
char = await self._read_ram_int(PLAYER_CLASS + (0x1F0 * (player - 1)), 1)
|
||||
color = await self._read_ram_int(PLAYER_COLOR + (0x1F0 * (player - 1)), 1)
|
||||
|
||||
# Play death sound
|
||||
sound_data = (int.to_bytes(colors[color], 4, "little") +
|
||||
int.to_bytes(sounds[char], 4, "little") +
|
||||
int.to_bytes(0xBB, 4, "little"))
|
||||
await self._write_ram(SOUND_ADDRESS, sound_data)
|
||||
await self._write_ram(SOUND_START, int.to_bytes(0xE00AE718, 4, "little"))
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Stop sound and kill player
|
||||
await self._write_ram(SOUND_START, int.to_bytes(0x0, 4, "little"))
|
||||
await self._write_ram(PLAYER_KILL + (0x1F0 * (player - 1)), int.to_bytes(0x7, 1, "little"))
|
||||
|
||||
def make_gui(self):
|
||||
ui = super().make_gui()
|
||||
ui.base_title = "Archipelago Gauntlet Legends Client"
|
||||
return ui
|
||||
|
||||
|
||||
async def gl_sync_task(ctx: GauntletLegendsContext):
|
||||
logger.info("Starting N64 connector...")
|
||||
while not ctx.exit_event.is_set():
|
||||
try:
|
||||
if not ctx.retro_connected:
|
||||
logger.info("Attempting to connect to Retroarch...")
|
||||
status = await ctx.socket.status()
|
||||
ctx.retro_connected = True
|
||||
ctx.rom_loaded = "CONTENTLESS" not in status
|
||||
logger.info("Connected to Retroarch")
|
||||
continue
|
||||
|
||||
if not ctx.rom_loaded:
|
||||
status = await ctx.socket.status()
|
||||
if "CONTENTLESS" in status:
|
||||
logger.info("No ROM loaded, waiting...")
|
||||
await asyncio.sleep(3)
|
||||
continue
|
||||
logger.info("ROM Loaded")
|
||||
ctx.rom_loaded = True
|
||||
|
||||
if not ctx.auth:
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
seed_name = await ctx.get_seed_name()
|
||||
if seed_name != ctx.seed_name[0:16]:
|
||||
logger.info(f"ROM seed does not match room seed ({seed_name} != {ctx.seed_name}), "
|
||||
f"please load the correct ROM.")
|
||||
await ctx.disconnect()
|
||||
continue
|
||||
|
||||
await ctx.update_stage()
|
||||
if ctx.zone == 0x10:
|
||||
continue
|
||||
|
||||
await ctx.handle_items()
|
||||
checking = await ctx.location_loop()
|
||||
|
||||
menu = await ctx._read_ram_int(PLAYER_MENU, 4)
|
||||
alive = False
|
||||
if ctx.deathlink_pending and ctx.deathlink_enabled and menu == 1:
|
||||
ctx.deathlink_pending = False
|
||||
ctx.deathlink_triggered = True
|
||||
await ctx.die()
|
||||
elif len(ctx.players) > 0:
|
||||
player_state = await ctx._read_ram_int(PLAYER_KILL + (0x1F0 * (ctx.players[0] - 1)), 1)
|
||||
dead = (player_state & 0xF) == 0x8
|
||||
alive = (player_state & 0xF) == 0x4
|
||||
if dead and ctx.deathlink_enabled and not ctx.deathlink_triggered:
|
||||
await ctx.send_death(f"{ctx.player_names[ctx.slot]} ran out of food.")
|
||||
ctx.deathlink_triggered = True
|
||||
|
||||
if alive:
|
||||
ctx.deathlink_triggered = False
|
||||
|
||||
if checking:
|
||||
ctx.locations_checked += checking
|
||||
await ctx.check_locations(checking)
|
||||
|
||||
# Only trust the goal check when the player is alive
|
||||
if alive:
|
||||
goal = await ctx.check_goal()
|
||||
if not ctx.finished_game and goal:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
else:
|
||||
ctx.goal_streak = 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}\n{traceback.format_exc()}")
|
||||
ctx.socket = RetroSocket()
|
||||
ctx.retro_connected = False
|
||||
await asyncio.sleep(2)
|
||||
|
||||
|
||||
_original_opt_content: dict[str, str | None] = {}
|
||||
|
||||
|
||||
async def _patch_opt():
|
||||
"""Create RetroArch core options override for CountPerOp=1."""
|
||||
retroarch_path = settings.get_settings().gl_options.retroarch_path
|
||||
override_dir = os.path.join(retroarch_path, "config", "Mupen64Plus-Next")
|
||||
os.makedirs(override_dir, exist_ok=True)
|
||||
override_path = os.path.join(override_dir, "Mupen64Plus-Next.opt")
|
||||
target_setting = 'mupen64plus-CountPerOp = "1"'
|
||||
|
||||
if override_path not in _original_opt_content:
|
||||
_original_opt_content[override_path] = open(override_path).read() if os.path.exists(override_path) else None
|
||||
|
||||
content = _original_opt_content[override_path] or ""
|
||||
if target_setting in content:
|
||||
return
|
||||
|
||||
if "mupen64plus-CountPerOp" in content:
|
||||
content = re.sub(r'mupen64plus-CountPerOp\s*=\s*"[^"]*"', target_setting, content)
|
||||
else:
|
||||
content = content.rstrip("\n") + f"\n{target_setting}\n" if content else f"{target_setting}\n"
|
||||
|
||||
with open(override_path, "w") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def _restore_opt_files():
|
||||
for path, original in _original_opt_content.items():
|
||||
try:
|
||||
if original is None and os.path.exists(path):
|
||||
os.remove(path)
|
||||
elif original is not None:
|
||||
with open(path, "w") as f:
|
||||
f.write(original)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to restore {path}: {e}")
|
||||
_original_opt_content.clear()
|
||||
|
||||
|
||||
async def _launch_retroarch(rom_path: str):
|
||||
retroarch_path = settings.get_settings().gl_options.retroarch_path
|
||||
retroarch_exe = os.path.join(retroarch_path, "retroarch.exe")
|
||||
core_path = os.path.join(retroarch_path, "cores", "mupen64plus_next_libretro.dll")
|
||||
|
||||
if not os.path.exists(retroarch_exe):
|
||||
logger.error(f"RetroArch not found at: {retroarch_exe}")
|
||||
return
|
||||
|
||||
if not os.path.exists(core_path):
|
||||
logger.error(f"Mupen64Plus core not found at: {core_path}")
|
||||
return
|
||||
|
||||
subprocess.Popen([retroarch_exe, "-L", core_path, rom_path])
|
||||
logger.info(f"Launched RetroArch with ROM: {rom_path}")
|
||||
|
||||
# Wait for RetroArch to start up
|
||||
await asyncio.sleep(2)
|
||||
|
||||
|
||||
async def _patch_and_launch_game(patch_file: str):
|
||||
metadata, output_file = Patch.create_rom_file(patch_file)
|
||||
await _patch_opt()
|
||||
await _launch_retroarch(output_file)
|
||||
|
||||
|
||||
def launch(*args):
|
||||
async def main(args):
|
||||
if args.patch_file:
|
||||
await asyncio.create_task(_patch_and_launch_game(args.patch_file))
|
||||
ctx = GauntletLegendsContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
ctx.gl_sync_task = asyncio.create_task(gl_sync_task(ctx), name="Gauntlet Legends Sync Task")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
_restore_opt_files()
|
||||
|
||||
parser = get_base_parser()
|
||||
parser.add_argument("patch_file", default="", type=str, nargs="?", help="Path to an APGL file")
|
||||
args = parser.parse_args(args)
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.just_fix_windows_console()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
@@ -0,0 +1,43 @@
|
||||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from BaseClasses import Item, ItemClassification
|
||||
from .Data import portals, obelisks, mirror_shards
|
||||
|
||||
|
||||
class ItemData:
|
||||
id: int
|
||||
item_name: str
|
||||
progression: ItemClassification
|
||||
rom_id: int
|
||||
frequency: int
|
||||
|
||||
def __init__(self, id: int | None = 0, item_name: str = "", progression: int = 0, rom_id: str = "0x0", frequency: int = 1):
|
||||
self.id = id
|
||||
self.item_name = item_name
|
||||
self.progression = ItemClassification(progression)
|
||||
self.rom_id = int(rom_id, 16)
|
||||
self.frequency = frequency
|
||||
|
||||
|
||||
|
||||
class GLItem(Item):
|
||||
game: str = "Gauntlet Legends"
|
||||
|
||||
def import_items() -> tuple[ItemData, ...]:
|
||||
import orjson
|
||||
import pkgutil
|
||||
|
||||
return tuple(ItemData(**item) for item in orjson.loads(pkgutil.get_data(__name__, "json/items.json").decode("utf-8")))
|
||||
|
||||
item_list: Final[tuple[ItemData, ...]] = import_items()
|
||||
item_table: Final[Mapping[str, ItemData]] = MappingProxyType({item.item_name: item for item in item_list})
|
||||
items_by_id: Final[Mapping[int, ItemData]] = MappingProxyType({item.id: item for item in item_list})
|
||||
|
||||
gauntlet_item_name_groups = {
|
||||
"Runestone": [f"Runestone {i}" for i in range(1, 14)],
|
||||
"Portal": portals.keys(),
|
||||
"Obelisk": obelisks,
|
||||
"Mirror Shard": mirror_shards
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
from typing import Final, List, Dict
|
||||
|
||||
from BaseClasses import Location
|
||||
|
||||
|
||||
class LocationData:
|
||||
name: str = ""
|
||||
id: int = 0
|
||||
difficulty: int = 0
|
||||
tags: Final[tuple[str, ...]]
|
||||
|
||||
def __init__(self, name, id, difficulty, tags=None):
|
||||
if tags is None:
|
||||
tags = []
|
||||
self.name = name
|
||||
self.id = id
|
||||
self.difficulty = difficulty
|
||||
self.tags = tags
|
||||
|
||||
|
||||
class GLLocation(Location):
|
||||
game: str = "Gauntlet Legends"
|
||||
|
||||
def import_locations() -> List[LocationData]:
|
||||
import orjson
|
||||
import pkgutil
|
||||
|
||||
return [LocationData(**loc) for loc in orjson.loads(pkgutil.get_data(__name__, "json/locations.json").decode("utf-8"))]
|
||||
|
||||
|
||||
def get_locations_by_tags(tags: str | List[str]) -> List[LocationData]:
|
||||
if isinstance(tags, str):
|
||||
tags = [tags]
|
||||
return [loc for loc in all_locations if any(tag in loc.tags for tag in tags)]
|
||||
|
||||
|
||||
all_locations: List[LocationData] = import_locations()
|
||||
|
||||
location_table: Dict[str, int] = {locData.name: locData.id for locData in all_locations}
|
||||
|
||||
location_id_to_name: Dict[int, str] = {locData.id: locData.name for locData in all_locations if locData.id is not None}
|
||||
|
||||
locationName_to_data: Dict[str, LocationData] = {locData.name: locData for locData in all_locations}
|
||||
@@ -0,0 +1,247 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from Options import Choice, PerGameCommonOptions, StartInventoryPool, Toggle, Range, DefaultOnToggle, \
|
||||
OptionSet, DeathLink
|
||||
|
||||
|
||||
class IncludedAreas(OptionSet):
|
||||
"""
|
||||
Select which areas will have their locations included in the randomization pool.
|
||||
Each selected area's stages and checks will be accessible in your world.
|
||||
Unselected areas will not be included in the location pool.
|
||||
Mountain is always available and cannot be excluded.
|
||||
"""
|
||||
display_name = "Included Areas"
|
||||
valid_keys = ["Castle", "Town", "Ice", "Battlefield"]
|
||||
default = ["Castle", "Town", "Ice", "Battlefield"]
|
||||
|
||||
|
||||
class ChestBarrels(Choice):
|
||||
"""
|
||||
Choose how you want Chests and Barrels to be randomized.
|
||||
None: Neither Chests nor Barrels will be added as locations.
|
||||
All Chests: Chests will be added as locations, Barrels will not.
|
||||
All Barrels: Barrels will be added as locations, Chests will not.
|
||||
All Both: Both Chests and Barrels will be added as locations.
|
||||
"""
|
||||
display_name = "Chests and Barrels"
|
||||
option_none = 0
|
||||
option_all_chests = 1
|
||||
option_all_barrels = 2
|
||||
option_all_both = 3
|
||||
default = 3
|
||||
|
||||
|
||||
class Obelisks(DefaultOnToggle):
|
||||
"""
|
||||
Obelisks will be added to the random pool as items.
|
||||
Activating all the obelisks in an area will unlock access to the next.
|
||||
Disabling this will lock obelisks in their original locations.
|
||||
"""
|
||||
|
||||
display_name = "Obelisks"
|
||||
|
||||
|
||||
class MirrorShards(DefaultOnToggle):
|
||||
"""
|
||||
Mirror Shards will be added to the random pool as items.
|
||||
Collecting all four mirror shards will unlock access to the Desecrated Temple.
|
||||
Disabling this will lock mirror shards in their original locations.
|
||||
"""
|
||||
|
||||
display_name = "Mirror Shards"
|
||||
|
||||
|
||||
class Portals(DefaultOnToggle):
|
||||
"""
|
||||
Level portals will be added to the random pool as items.
|
||||
Getting a portal item will unlock access to that level.
|
||||
Levels will no longer unlock in succession when clearing the previous level.
|
||||
"""
|
||||
display_name = "Portals"
|
||||
|
||||
|
||||
class Goal(Choice):
|
||||
"""
|
||||
Choose your objective required to goal.
|
||||
Defeat Skorne: Collect all 13 stones to unlock and defeat Skorne in the Underworld.
|
||||
Defeat X Bosses: Defeat X number of bosses to goal.
|
||||
"""
|
||||
|
||||
display_name = "Goal"
|
||||
option_defeat_skorne = 1
|
||||
option_defeat_x_bosses = 2
|
||||
default = 1
|
||||
|
||||
|
||||
class BossGoalCount(Range):
|
||||
"""
|
||||
Choose how many bosses you must defeat to goal if 'Defeat X Bosses' is selected as your goal.
|
||||
This includes both Temple and Underworld Skorne.
|
||||
"""
|
||||
|
||||
display_name = "Boss Goal Count"
|
||||
range_start = 1
|
||||
range_end = 6
|
||||
default = 4
|
||||
|
||||
|
||||
class MaxDifficulty(Range):
|
||||
"""
|
||||
Select the difficulty value you want to be the maximum.
|
||||
This will affect the amount of checks in each level as well as enemy strength and number of spawners.
|
||||
"""
|
||||
|
||||
display_name = "Max Difficulty Value"
|
||||
range_start = 1
|
||||
range_end = 4
|
||||
default = 4
|
||||
|
||||
|
||||
class InstantMaxDifficulty(DefaultOnToggle):
|
||||
"""
|
||||
All stages will load with their max difficulty on the first run through.
|
||||
By default, stages increase in difficulty by 1 at a set interval that changes per zone.
|
||||
The starting level for each zone increases gradually as you would progress in vanilla.
|
||||
"""
|
||||
|
||||
display_name = "Instant Max Difficulty"
|
||||
|
||||
|
||||
class PermaSpeed(Toggle):
|
||||
"""
|
||||
You will be given speed boots with a permanent duration.
|
||||
"""
|
||||
|
||||
display_name = "Permanent Speed Boots"
|
||||
|
||||
|
||||
class InfiniteKeys(Toggle):
|
||||
"""
|
||||
You will be given more keys than you could ever use.
|
||||
"""
|
||||
|
||||
display_name = "Infinite Keys"
|
||||
|
||||
|
||||
class IncludedTraps(OptionSet):
|
||||
"""
|
||||
Choose what traps will be put in the item pool.
|
||||
Valid Keys: Death, Poison Fruit, Crossbow Shooter, Bomb Thrower, Bomb Runner, Golem
|
||||
"""
|
||||
|
||||
display_name = "Included Traps"
|
||||
valid_keys = ["Death", "Poison Fruit", "Crossbow Shooter", "Bomb Thrower", "Bomb Runner", "Golem"]
|
||||
default = ["Death", "Poison Fruit", "Crossbow Shooter", "Bomb Thrower", "Bomb Runner", "Golem"]
|
||||
|
||||
|
||||
class TrapsFrequency(Range):
|
||||
"""
|
||||
Choose the frequency of traps added into the item pool
|
||||
This range is a percentage of all items in the pool that will be traps.
|
||||
"""
|
||||
|
||||
display_name = "Trap Frequency"
|
||||
range_start = 0
|
||||
range_end = 90
|
||||
default = 10
|
||||
|
||||
|
||||
class LocalFillerFrequency(Range):
|
||||
"""
|
||||
Choose the frequency of filler items that will be placed locally in your world.
|
||||
This range is a percentage of how many filler items will be locally placed before generation occurs.
|
||||
"""
|
||||
|
||||
display_name = "Local Filler Frequency"
|
||||
range_start = 0
|
||||
range_end = 90
|
||||
default = 50
|
||||
|
||||
|
||||
class UnlockCharacterOne(Choice):
|
||||
"""
|
||||
Unlock a secret character for Player 1 from the start.
|
||||
None: No secret characters will be unlocked.
|
||||
Chosen Character: The selected character will be available from a new save.
|
||||
"""
|
||||
|
||||
option_none = 0
|
||||
option_minotaur = 1
|
||||
option_falconess = 2
|
||||
option_jackal = 3
|
||||
option_tigress = 4
|
||||
option_sumner = 5
|
||||
default = 0
|
||||
|
||||
|
||||
class UnlockCharacterTwo(Choice):
|
||||
"""
|
||||
Unlock a secret character for Player 2 from the start.
|
||||
None: No secret characters will be unlocked.
|
||||
Chosen Character: The selected character will be available from a new save.
|
||||
"""
|
||||
|
||||
option_none = 0
|
||||
option_minotaur = 1
|
||||
option_falconess = 2
|
||||
option_jackal = 3
|
||||
option_tigress = 4
|
||||
option_sumner = 5
|
||||
default = 0
|
||||
|
||||
|
||||
class UnlockCharacterThree(Choice):
|
||||
"""
|
||||
Unlock a secret character for Player 3 from the start.
|
||||
None: No secret characters will be unlocked.
|
||||
Chosen Character: The selected character will be available from a new save.
|
||||
"""
|
||||
|
||||
option_none = 0
|
||||
option_minotaur = 1
|
||||
option_falconess = 2
|
||||
option_jackal = 3
|
||||
option_tigress = 4
|
||||
option_sumner = 5
|
||||
default = 0
|
||||
|
||||
|
||||
class UnlockCharacterFour(Choice):
|
||||
"""
|
||||
Unlock a secret character for Player 4 from the start.
|
||||
None: No secret characters will be unlocked.
|
||||
Chosen Character: The selected character will be available from a new save.
|
||||
"""
|
||||
|
||||
option_none = 0
|
||||
option_minotaur = 1
|
||||
option_falconess = 2
|
||||
option_jackal = 3
|
||||
option_tigress = 4
|
||||
option_sumner = 5
|
||||
default = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class GLOptions(PerGameCommonOptions):
|
||||
death_link: DeathLink
|
||||
start_inventory_from_pool: StartInventoryPool
|
||||
included_areas: IncludedAreas
|
||||
chests_barrels: ChestBarrels
|
||||
obelisks: Obelisks
|
||||
mirror_shards: MirrorShards
|
||||
portals: Portals
|
||||
goal: Goal
|
||||
boss_goal_count: BossGoalCount
|
||||
max_difficulty: MaxDifficulty
|
||||
instant_max: InstantMaxDifficulty
|
||||
infinite_keys: InfiniteKeys
|
||||
permanent_speed: PermaSpeed
|
||||
included_traps: IncludedTraps
|
||||
traps_frequency: TrapsFrequency
|
||||
local_filler_frequency: LocalFillerFrequency
|
||||
unlock_character_one: UnlockCharacterOne
|
||||
unlock_character_two: UnlockCharacterTwo
|
||||
unlock_character_three: UnlockCharacterThree
|
||||
unlock_character_four: UnlockCharacterFour
|
||||
@@ -0,0 +1,137 @@
|
||||
import typing
|
||||
from typing import Optional, Callable
|
||||
|
||||
from BaseClasses import Region
|
||||
from .Data import excluded_levels
|
||||
|
||||
from .Locations import GLLocation, LocationData, get_locations_by_tags
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from . import GauntletLegendsWorld
|
||||
|
||||
|
||||
def create_regions(world: "GauntletLegendsWorld"):
|
||||
world.multiworld.regions.append(Region("Menu", world.player, world.multiworld))
|
||||
|
||||
regions_dict = get_regions_dict()
|
||||
for name, locations in regions_dict.items():
|
||||
if name not in [level for region, levels in excluded_levels.items() if region in world.excluded_regions for level in levels]:
|
||||
create_region(world, name, locations)
|
||||
else:
|
||||
world.disabled_locations.update([loc.name for loc in locations if loc.name not in world.disabled_locations])
|
||||
|
||||
def get_regions_dict() -> dict[str, list[LocationData]]:
|
||||
"""
|
||||
Returns a dictionary mapping region names to their corresponding location data lists.
|
||||
"""
|
||||
return {
|
||||
"Valley of Fire": get_locations_by_tags("valley_of_fire"),
|
||||
"Dagger Peak": get_locations_by_tags("dagger_peak"),
|
||||
"Cliffs of Desolation": get_locations_by_tags("cliffs_of_desolation"),
|
||||
"Lost Cave": get_locations_by_tags("lost_cave"),
|
||||
"Volcanic Caverns": get_locations_by_tags("volcanic_cavern"),
|
||||
"Dragon's Lair": get_locations_by_tags("dragons_lair"),
|
||||
"Castle Courtyard": get_locations_by_tags("castle_courtyard"),
|
||||
"Dungeon of Torment": get_locations_by_tags("dungeon_of_torment"),
|
||||
"Tower Armory": get_locations_by_tags("tower_armory"),
|
||||
"Castle Treasury": get_locations_by_tags("castle_treasury"),
|
||||
"Chimera's Keep": get_locations_by_tags("chimeras_keep"),
|
||||
"Poisoned Fields": get_locations_by_tags("poisoned_fields"),
|
||||
"Haunted Cemetery": get_locations_by_tags("haunted_cemetery"),
|
||||
"Venomous Spire": get_locations_by_tags("venomous_spire"),
|
||||
"Toxic Air Ship": get_locations_by_tags("toxic_air_ship"),
|
||||
"Vat of the Plague Fiend": get_locations_by_tags("plague_fiend"),
|
||||
"Arctic Docks": get_locations_by_tags("arctic_docks"),
|
||||
"Frozen Camp": get_locations_by_tags("frozen_camp"),
|
||||
"Crystal Mine": get_locations_by_tags("crystal_mine"),
|
||||
"Erupting Fissure": get_locations_by_tags("erupting_fissure"),
|
||||
"Yeti's Cavern": get_locations_by_tags("yeti"),
|
||||
"Desecrated Temple": get_locations_by_tags("desecrated_temple"),
|
||||
"Altar of Skorne": get_locations_by_tags("altar_of_skorne"),
|
||||
"Battle Trenches": get_locations_by_tags("battle_trenches"),
|
||||
"Fortified Towers": get_locations_by_tags("fortified_towers"),
|
||||
"Infernal Fortress": get_locations_by_tags("infernal_fortress"),
|
||||
"Gates of the Underworld": get_locations_by_tags("gates_of_the_underworld")
|
||||
}
|
||||
|
||||
|
||||
def connect_regions(world: "GauntletLegendsWorld"):
|
||||
names: dict[str, int] = {}
|
||||
|
||||
connect(world, names, "Menu", "Valley of Fire")
|
||||
connect(world, names, "Menu", "Dagger Peak", _portal_helper("Portal to Dagger Peak", world))
|
||||
connect(world, names, "Menu", "Cliffs of Desolation", _portal_helper("Portal to Cliffs of Desolation", world))
|
||||
connect(world, names, "Menu", "Lost Cave", _portal_helper("Portal to Lost Cave", world))
|
||||
connect(world, names, "Menu", "Volcanic Caverns", _portal_helper("Portal to Volcanic Caverns", world))
|
||||
connect(world, names, "Menu", "Dragon's Lair", _portal_helper("Portal to Dragon's Lair", world))
|
||||
connect(world, names, "Menu", "Castle Courtyard",
|
||||
lambda state: state.has("Valley of Fire Obelisk", world.player)
|
||||
and state.has("Dagger Peak Obelisk", world.player)
|
||||
and state.has("Cliffs of Desolation Obelisk", world.player)
|
||||
)
|
||||
connect(world, names, "Castle Courtyard", "Dungeon of Torment", _portal_helper("Portal to Dungeon of Torment", world))
|
||||
connect(world, names, "Castle Courtyard", "Tower Armory", _portal_helper("Portal to Tower Armory", world))
|
||||
connect(world, names, "Castle Courtyard", "Castle Treasury", _portal_helper("Portal to Castle Treasury", world))
|
||||
connect(world, names, "Castle Courtyard", "Chimera's Keep", _portal_helper("Portal to Chimera's Keep", world))
|
||||
connect(world, names, "Menu", "Poisoned Fields",
|
||||
lambda state: state.has("Castle Courtyard Obelisk", world.player)
|
||||
and state.has("Dungeon of Torment Obelisk", world.player)
|
||||
)
|
||||
connect(world, names, "Poisoned Fields", "Haunted Cemetery", _portal_helper("Portal to Haunted Cemetery", world))
|
||||
connect(world, names, "Poisoned Fields", "Venomous Spire", _portal_helper("Portal to Venomous Spire", world))
|
||||
connect(world, names, "Poisoned Fields", "Toxic Air Ship", _portal_helper("Portal to Toxic Air Ship", world))
|
||||
connect(world, names, "Toxic Air Ship", "Vat of the Plague Fiend", _portal_helper("Portal to Vat of the Plague Fiend", world))
|
||||
connect(world, names, "Menu", "Arctic Docks",
|
||||
lambda state: state.has("Poisoned Fields Obelisk", world.player)
|
||||
and state.has("Haunted Cemetery Obelisk", world.player)
|
||||
)
|
||||
connect(world, names, "Arctic Docks", "Frozen Camp", _portal_helper("Portal to Frozen Camp", world))
|
||||
connect(world, names, "Arctic Docks", "Crystal Mine", _portal_helper("Portal to Crystal Mine", world))
|
||||
connect(world, names, "Arctic Docks", "Erupting Fissure", _portal_helper("Portal to Erupting Fissure", world))
|
||||
connect(world, names, "Erupting Fissure", "Yeti's Cavern", _portal_helper("Portal to Yeti's Cavern", world))
|
||||
connect(world, names, "Menu", "Desecrated Temple",
|
||||
lambda state: state.has("Dragon Mirror Shard", world.player)
|
||||
and state.has("Chimera Mirror Shard", world.player)
|
||||
and state.has("Plague Fiend Mirror Shard", world.player)
|
||||
and state.has("Yeti Mirror Shard", world.player)
|
||||
)
|
||||
connect(world, names, "Desecrated Temple", "Altar of Skorne")
|
||||
connect(world, names, "Desecrated Temple", "Battle Trenches")
|
||||
connect(world, names, "Desecrated Temple", "Fortified Towers", _portal_helper("Portal to Fortified Towers", world))
|
||||
connect(world, names, "Desecrated Temple", "Infernal Fortress", _portal_helper("Portal to Infernal Fortress", world))
|
||||
connect(world, names, "Menu", "Gates of the Underworld",
|
||||
lambda state: state.has("stones", world.player, 13)
|
||||
)
|
||||
|
||||
def _portal_helper(item: str, world: "GauntletLegendsWorld") -> Callable | None:
|
||||
if world.options.portals.value:
|
||||
return lambda state: state.has(item, world.player)
|
||||
return None
|
||||
|
||||
|
||||
def create_region(world: "GauntletLegendsWorld", name: str, locations: list[LocationData]):
|
||||
reg = Region(name, world.player, world.multiworld)
|
||||
reg.add_locations({loc.name: loc.id for loc in locations if loc.name not in world.disabled_locations}, GLLocation)
|
||||
world.multiworld.regions.append(reg)
|
||||
|
||||
|
||||
def connect(world: "GauntletLegendsWorld",
|
||||
used_names: dict[str, int],
|
||||
source: str,
|
||||
target: str,
|
||||
rule: Optional[Callable] = None,
|
||||
):
|
||||
excluded_levels_ = [level for region, levels in excluded_levels.items() if region in world.excluded_regions for level in levels]
|
||||
if source in excluded_levels_ or target in excluded_levels_:
|
||||
return
|
||||
source_region = world.multiworld.get_region(source, world.player)
|
||||
target_region = world.multiworld.get_region(target, world.player)
|
||||
|
||||
if target not in used_names:
|
||||
used_names[target] = 1
|
||||
name = target
|
||||
else:
|
||||
used_names[target] += 1
|
||||
name = target + (" " * used_names[target])
|
||||
|
||||
source_region.connect(target_region, name, rule)
|
||||
@@ -0,0 +1,613 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import pkgutil
|
||||
import traceback
|
||||
import typing
|
||||
import zlib
|
||||
from random import Random
|
||||
|
||||
import bsdiff4
|
||||
|
||||
import Utils
|
||||
from BaseClasses import Location, ItemClassification
|
||||
|
||||
from worlds.Files import APPatchExtension, APProcedurePatch, APTokenMixin
|
||||
|
||||
from .Data import level_address, level_header, level_locations, level_size, boss_location_offsets, spawner_trap_ids
|
||||
from .Items import ItemData, items_by_id
|
||||
from .Locations import locationName_to_data, GLLocation
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from . import GauntletLegendsWorld
|
||||
|
||||
TABLE_START_OFFSET = 0x12E0
|
||||
EXPANDED_GAME_ROM_OFFSET = 0x1000000 # 16MB mark
|
||||
|
||||
|
||||
def create_spawner_from_item(item_data: bytearray, spawner_rom_id: int, difficulty: int) -> bytearray:
|
||||
spawner = bytearray(16)
|
||||
spawner[0:6] = item_data[0:6]
|
||||
spawner[6:8] = (0).to_bytes(2, "big")
|
||||
spawner[8:12] = spawner_rom_id.to_bytes(4, "big")
|
||||
spawner[12:14] = (0x0003).to_bytes(2, "big")
|
||||
spawner[14] = difficulty
|
||||
spawner[15] = 0x00
|
||||
return spawner
|
||||
|
||||
|
||||
def create_spawner_from_chest(chest_data: bytearray, spawner_rom_id: int, difficulty: int) -> bytearray:
|
||||
spawner = bytearray(16)
|
||||
spawner[0:6] = chest_data[0:6]
|
||||
spawner[6:8] = chest_data[6:8]
|
||||
spawner[8:12] = spawner_rom_id.to_bytes(4, "big")
|
||||
spawner[12:14] = (0x0003).to_bytes(2, "big")
|
||||
spawner[14] = difficulty
|
||||
spawner[15] = 0x00
|
||||
return spawner
|
||||
|
||||
|
||||
def be32(b: bytes) -> int:
|
||||
return int.from_bytes(b, "big")
|
||||
|
||||
|
||||
def write_be32(buf: bytearray, off: int, val: int):
|
||||
buf[off:off + 4] = (val & 0xFFFFFFFF).to_bytes(4, "big")
|
||||
|
||||
|
||||
def ensure_len(buf: bytearray, size: int, fill: int = 0):
|
||||
if len(buf) < size:
|
||||
buf.extend(bytes([fill]) * (size - len(buf)))
|
||||
|
||||
|
||||
def n64_crc(rom: bytes, cic: int = 6102) -> tuple[int, int]:
|
||||
"""Calculate N64 CRC checksums."""
|
||||
if cic in (6101, 6102):
|
||||
seed = 0xF8CA4DDC
|
||||
elif cic == 6103:
|
||||
seed = 0xA3886759
|
||||
elif cic == 6105:
|
||||
seed = 0xDF26F436
|
||||
elif cic == 6106:
|
||||
seed = 0x1FEA617A
|
||||
else:
|
||||
raise ValueError("Unsupported CIC")
|
||||
|
||||
t1 = t2 = t3 = t4 = t5 = t6 = seed
|
||||
|
||||
for i in range(0x1000, 0x101000, 4):
|
||||
d = int.from_bytes(rom[i:i + 4], "big")
|
||||
|
||||
if (t6 + d) & 0xFFFFFFFF < t6:
|
||||
t4 = (t4 + 1) & 0xFFFFFFFF
|
||||
|
||||
t6 = (t6 + d) & 0xFFFFFFFF
|
||||
t3 ^= d
|
||||
|
||||
r = ((d << (d & 31)) | (d >> (32 - (d & 31)))) & 0xFFFFFFFF
|
||||
t5 = (t5 + r) & 0xFFFFFFFF
|
||||
|
||||
if t2 > d:
|
||||
t2 ^= r
|
||||
else:
|
||||
t2 ^= t6 ^ d
|
||||
|
||||
if cic == 6105:
|
||||
extra = int.from_bytes(rom[0x0750 + (i & 0xFF):0x0754 + (i & 0xFF)], "big")
|
||||
t1 = (t1 + (extra ^ d)) & 0xFFFFFFFF
|
||||
else:
|
||||
t1 = (t1 + (t5 ^ d)) & 0xFFFFFFFF
|
||||
|
||||
if cic == 6103:
|
||||
crc1 = (t6 ^ t4) & 0xFFFFFFFF
|
||||
crc2 = (t5 ^ t3) & 0xFFFFFFFF
|
||||
elif cic == 6106:
|
||||
crc1 = ((t6 * t4) + t3) & 0xFFFFFFFF
|
||||
crc2 = ((t5 * t2) + t1) & 0xFFFFFFFF
|
||||
else:
|
||||
crc1 = (t6 ^ t4 ^ t3) & 0xFFFFFFFF
|
||||
crc2 = (t5 ^ t2 ^ t1) & 0xFFFFFFFF
|
||||
|
||||
return crc1, crc2
|
||||
|
||||
|
||||
def get_base_rom_as_bytes() -> bytes:
|
||||
"""
|
||||
Read the base ROM file and verify its MD5 hash.
|
||||
Raises an exception if the ROM doesn't match the expected hash.
|
||||
"""
|
||||
try:
|
||||
from . import GauntletLegendsWorld
|
||||
GauntletLegendsWorld.settings.rom_file.validate(GauntletLegendsWorld.settings.rom_file)
|
||||
with open(GauntletLegendsWorld.settings.rom_file, "rb") as infile:
|
||||
base_rom_bytes = bytes(infile.read())
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
raise Exception('Failed to read ROM file. Check file path and permissions.')
|
||||
return base_rom_bytes
|
||||
|
||||
|
||||
def get_base_rom_path(file_name: str = "") -> str:
|
||||
if not os.path.exists(file_name):
|
||||
file_name = Utils.user_path(file_name)
|
||||
return file_name
|
||||
|
||||
|
||||
# Contains header info and raw data for level item positions and rotations.
|
||||
class LevelData:
|
||||
stream: io.BytesIO
|
||||
header: bytearray
|
||||
item_addr: int
|
||||
spawner_addr: int
|
||||
obj_addr: int
|
||||
chest_addr: int
|
||||
end_addr: int
|
||||
end_addr2: int
|
||||
end_addr3: int
|
||||
portal_addr: int
|
||||
items: list[bytearray]
|
||||
spawners: list[bytearray]
|
||||
objects: list[bytearray]
|
||||
chests: list[bytearray]
|
||||
end: bytes
|
||||
items_replaced_by_obelisks: int = 0
|
||||
chests_replaced_by_obelisks: int = 0
|
||||
chests_replaced_by_items: int = 0
|
||||
obelisks_replaced_by_items: int = 0
|
||||
obelisks_kept: int = 0
|
||||
items_replaced_by_spawners: int = 0
|
||||
chests_replaced_by_spawners: int = 0
|
||||
|
||||
def __init__(self):
|
||||
self.items = []
|
||||
self.spawners = []
|
||||
self.objects = []
|
||||
self.chests = []
|
||||
self.end = b""
|
||||
|
||||
|
||||
class GLPatchExtension(APPatchExtension):
|
||||
game = "Gauntlet Legends"
|
||||
|
||||
# Patched ROM requires updated CRC values.
|
||||
@staticmethod
|
||||
def finalize_crc(caller: APProcedurePatch, rom: bytes) -> bytes:
|
||||
options = json.loads(caller.get_file("options.json").decode("utf-8"))
|
||||
rom = bytearray(rom)
|
||||
|
||||
SEED_ROM_OFFSET = 0xFFFFF0
|
||||
SEED_LEN = 16
|
||||
|
||||
seed = options["seed_name"].encode("utf-8")[:SEED_LEN]
|
||||
rom[SEED_ROM_OFFSET:SEED_ROM_OFFSET + SEED_LEN] = seed.ljust(SEED_LEN, b"\x00")
|
||||
|
||||
rom = bytearray(rom)
|
||||
crc1, crc2 = n64_crc(rom, cic=6102)
|
||||
rom[0x10:0x14] = crc1.to_bytes(4, "big")
|
||||
rom[0x14:0x18] = crc2.to_bytes(4, "big")
|
||||
return bytes(rom)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def apply_bsdiff4(caller: APProcedurePatch, rom: bytes, patch: str) -> bytes:
|
||||
return bsdiff4.patch(rom, pkgutil.get_data(__name__, f"data/basepatch.bsdiff4"))
|
||||
|
||||
# Decompress all levels, place all items in the levels.
|
||||
@staticmethod
|
||||
def patch_items(caller: APProcedurePatch, rom: bytes):
|
||||
stream = io.BytesIO(rom)
|
||||
options = json.loads(caller.get_file("options.json").decode("UTF-8"))
|
||||
local_random = Random(options["seed"])
|
||||
player = options["player"]
|
||||
|
||||
for i in range(len(level_locations)):
|
||||
level: dict[str, tuple] = json.loads(caller.get_file(f"level_{i}.json").decode("utf-8"))
|
||||
level_address_ = level_address[i]
|
||||
if level_address_ == 0:
|
||||
continue
|
||||
stream.seek(level_address_, 0)
|
||||
stream, data = get_level_data(stream, level_size[i], i)
|
||||
|
||||
items_seen = 0
|
||||
items_deleted = 0
|
||||
chests_deleted = 0
|
||||
chests_seen = 0
|
||||
|
||||
for location_name, item in level.items():
|
||||
is_chest = chest_barrel(location_name)
|
||||
if is_chest:
|
||||
chest_index = chests_seen - chests_deleted
|
||||
chests_seen += 1
|
||||
else:
|
||||
item_index = items_seen - items_deleted
|
||||
items_seen += 1
|
||||
|
||||
if item[0] == 0:
|
||||
continue
|
||||
if "Mirror" in location_name:
|
||||
continue
|
||||
|
||||
item_data = items_by_id.get(item[0], ItemData())
|
||||
rom_id = item_data.rom_id
|
||||
if rom_id == 0x0302:
|
||||
rom_id = local_random.choices(
|
||||
[0x0300, 0x0301, 0x0302, 0x0303, 0x0304],
|
||||
weights=[10, 20, 40, 20, 10]
|
||||
)[0]
|
||||
|
||||
# Handle obelisk locations
|
||||
if "Obelisk" in location_name:
|
||||
if "Obelisk" not in item_data.item_name:
|
||||
# Non-obelisk item at obelisk location - convert obelisk to item
|
||||
try:
|
||||
index = next(idx for idx in range(len(data.objects)) if data.objects[idx][8] == 0x26)
|
||||
data.items.append(
|
||||
bytearray(data.objects[index][0:6])
|
||||
+ (rom_id.to_bytes(2) if item[1] == player else bytes([0x27, 0x1C]))
|
||||
+ bytes([0x0, 0x0, 0x0, 0x0])
|
||||
)
|
||||
del data.objects[index]
|
||||
data.obelisks_replaced_by_items += 1
|
||||
except (StopIteration, Exception):
|
||||
pass
|
||||
else:
|
||||
# Obelisk item at obelisk location
|
||||
try:
|
||||
index = next(idx for idx in range(len(data.objects)) if data.objects[idx][8] == 0x26)
|
||||
if item[1] == player:
|
||||
data.objects[index][15] = item[0] - 77780054
|
||||
data.obelisks_kept += 1
|
||||
else:
|
||||
data.items.append(
|
||||
bytearray(data.objects[index][0:6])
|
||||
+ bytes([0x27, 0x1C, 0x0, 0x0, 0x0, 0x0])
|
||||
)
|
||||
del data.objects[index]
|
||||
data.obelisks_replaced_by_items += 1
|
||||
except (StopIteration, Exception):
|
||||
pass
|
||||
continue
|
||||
|
||||
# Handle spawner trap items (local player only)
|
||||
if item[1] == player and item[0] in spawner_trap_ids:
|
||||
difficulty = locationName_to_data[location_name].difficulty
|
||||
spawner_rom_id = items_by_id[item[0]].rom_id
|
||||
if is_chest:
|
||||
data.spawners.append(create_spawner_from_chest(data.chests[chest_index], spawner_rom_id, difficulty))
|
||||
del data.chests[chest_index]
|
||||
chests_deleted += 1
|
||||
data.chests_replaced_by_spawners += 1
|
||||
else:
|
||||
data.spawners.append(create_spawner_from_item(data.items[item_index], spawner_rom_id, difficulty))
|
||||
del data.items[item_index]
|
||||
items_deleted += 1
|
||||
data.items_replaced_by_spawners += 1
|
||||
continue
|
||||
|
||||
# Handle non-local player items
|
||||
if item[1] != player:
|
||||
if is_chest:
|
||||
data.chests[chest_index][12:14] = [0x27, 0x1C]
|
||||
if "Chest" in location_name:
|
||||
data.chests[chest_index][9] = 0x1
|
||||
else:
|
||||
data.items[item_index][6:8] = [0x27, 0x1C]
|
||||
continue
|
||||
|
||||
# Handle local player items
|
||||
if "Obelisk" in items_by_id[item[0]].item_name:
|
||||
# Convert item/chest to obelisk
|
||||
if is_chest:
|
||||
slice_ = bytearray(data.chests[chest_index][0:6])
|
||||
del data.chests[chest_index]
|
||||
chests_deleted += 1
|
||||
data.chests_replaced_by_obelisks += 1
|
||||
else:
|
||||
slice_ = bytearray(data.items[item_index][0:6])
|
||||
del data.items[item_index]
|
||||
items_deleted += 1
|
||||
data.items_replaced_by_obelisks += 1
|
||||
data.objects.append(
|
||||
slice_ + bytearray([
|
||||
0x0, 0x0, 0x26, 0x1, 0x0,
|
||||
locationName_to_data[location_name].difficulty,
|
||||
0x0, 0x0, 0x0, item[0] - 77780054,
|
||||
0x3F, 0x80, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
||||
])
|
||||
)
|
||||
elif item_data.progression in (ItemClassification.useful, ItemClassification.progression) and is_chest:
|
||||
# Convert chest to item for useful/progression items
|
||||
chest = data.chests[chest_index]
|
||||
data.items.append(
|
||||
bytearray(chest[0:6])
|
||||
+ rom_id.to_bytes(2)
|
||||
+ bytes([chest[11], 0x0, 0x0, 0x0])
|
||||
)
|
||||
del data.chests[chest_index]
|
||||
chests_deleted += 1
|
||||
data.chests_replaced_by_items += 1
|
||||
else:
|
||||
# Regular item placement
|
||||
if is_chest:
|
||||
data.chests[chest_index][12:14] = rom_id.to_bytes(2)
|
||||
if "Chest" in location_name:
|
||||
data.chests[chest_index][9] = 0x2
|
||||
else:
|
||||
data.items[item_index][6:8] = item_data.rom_id.to_bytes(2)
|
||||
|
||||
# Write level data
|
||||
uncompressed = level_data_reformat(data)
|
||||
compressed = zenc(uncompressed)
|
||||
stream.seek(level_header[i] + 4, 0)
|
||||
stream.write(len(compressed).to_bytes(4, byteorder="big"))
|
||||
stream.write(len(uncompressed).to_bytes(4, byteorder="big"))
|
||||
write_pos = 0xFA1000 + (0x1500 * i)
|
||||
stream.write((write_pos - 0x636E0).to_bytes(4, byteorder="big"))
|
||||
stream.seek(write_pos, 0)
|
||||
stream.write(compressed)
|
||||
|
||||
return stream.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def patch_bins(caller: APProcedurePatch, rom: bytes) -> bytes:
|
||||
boss_items_data = json.loads(caller.get_file("boss_items.json").decode("UTF-8"))
|
||||
options = json.loads(caller.get_file("options.json").decode("UTF-8"))
|
||||
local_random = Random(options["seed"])
|
||||
|
||||
boss_entry_offset = TABLE_START_OFFSET + (2 * 0x30)
|
||||
|
||||
rom = bytearray(rom)
|
||||
|
||||
boss_rom_offset = be32(rom[boss_entry_offset + 0x10:boss_entry_offset + 0x14])
|
||||
boss_comp_size = be32(rom[boss_entry_offset + 0x14:boss_entry_offset + 0x18])
|
||||
|
||||
boss_compressed = bytes(rom[boss_rom_offset:boss_rom_offset + boss_comp_size])
|
||||
boss_decompressed = bytearray(zdec(boss_compressed))
|
||||
|
||||
FILLER_ROM_ID = 0x271C
|
||||
|
||||
for location_name, item_data in boss_items_data.items():
|
||||
if location_name not in boss_location_offsets:
|
||||
continue
|
||||
|
||||
item_code, item_player = item_data
|
||||
if not item_code:
|
||||
continue
|
||||
|
||||
if item_player != options["player"]:
|
||||
rom_id = FILLER_ROM_ID
|
||||
else:
|
||||
rom_id = items_by_id[item_code].rom_id
|
||||
|
||||
if rom_id == 0x0302:
|
||||
rom_id = local_random.choices([0x0300, 0x0301, 0x0302, 0x0303, 0x0304], weights=[10, 20, 40, 20, 10])[0]
|
||||
|
||||
hi_byte = (rom_id >> 8) & 0xFF
|
||||
lo_byte = rom_id & 0xFF
|
||||
|
||||
offset = boss_location_offsets[location_name]
|
||||
boss_decompressed[offset:offset + 2] = hi_byte.to_bytes(2, "big")
|
||||
boss_decompressed[offset + 2:offset + 4] = lo_byte.to_bytes(2, "big")
|
||||
|
||||
boss_recompressed = zenc(boss_decompressed)
|
||||
new_boss_comp_size = len(boss_recompressed)
|
||||
|
||||
if new_boss_comp_size > boss_comp_size:
|
||||
new_boss_rom_offset = EXPANDED_GAME_ROM_OFFSET + 0x100000
|
||||
new_boss_end = new_boss_rom_offset + new_boss_comp_size
|
||||
ensure_len(rom, new_boss_end, fill=0xFF)
|
||||
rom[new_boss_rom_offset:new_boss_end] = boss_recompressed
|
||||
write_be32(rom, boss_entry_offset + 0x10, new_boss_rom_offset)
|
||||
write_be32(rom, boss_entry_offset + 0x14, new_boss_comp_size)
|
||||
else:
|
||||
rom[boss_rom_offset:boss_rom_offset + new_boss_comp_size] = boss_recompressed
|
||||
write_be32(rom, boss_entry_offset + 0x14, new_boss_comp_size)
|
||||
if new_boss_comp_size < boss_comp_size:
|
||||
leftover_start = boss_rom_offset + new_boss_comp_size
|
||||
leftover_end = boss_rom_offset + boss_comp_size
|
||||
rom[leftover_start:leftover_end] = bytes(leftover_end - leftover_start)
|
||||
|
||||
# Write portals option
|
||||
rom[0xFFFFE0] = options["portals"]
|
||||
rom[0xFFFFE1] = options["instant_max"]
|
||||
rom[0xFFFFE2] = options["max"]
|
||||
rom[0xFFFFE4] = options["keys"]
|
||||
rom[0xFFFFE5] = options["speed"]
|
||||
rom[0xFFFFE8:0xFFFFEC] = options["characters"]
|
||||
return bytes(rom)
|
||||
|
||||
|
||||
class GLProcedurePatch(APProcedurePatch, APTokenMixin):
|
||||
game = "Gauntlet Legends"
|
||||
hash = "9cb963e8b71f18568f78ec1af120362e"
|
||||
patch_file_ending = ".apgl"
|
||||
result_file_ending = ".z64"
|
||||
|
||||
procedure = [
|
||||
("apply_bsdiff4", ["basepatch.bsdiff4"]),
|
||||
("patch_bins", []),
|
||||
("patch_items", []),
|
||||
("finalize_crc", [])
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_source_data(cls) -> bytes:
|
||||
return get_base_rom_as_bytes()
|
||||
|
||||
|
||||
# Write data on all placed items into json files.
|
||||
# Also save options
|
||||
def write_files(world: "GauntletLegendsWorld", patch: GLProcedurePatch) -> None:
|
||||
options_dict = {
|
||||
"seed": world.multiworld.seed,
|
||||
"seed_name": world.multiworld.seed_name,
|
||||
"player": world.player,
|
||||
"portals": world.options.portals.value,
|
||||
"instant_max": world.options.instant_max.value,
|
||||
"max": world.options.max_difficulty.value,
|
||||
"keys": world.options.infinite_keys.value,
|
||||
"speed": world.options.permanent_speed.value,
|
||||
"characters": [world.options.unlock_character_one.value, world.options.unlock_character_two.value,
|
||||
world.options.unlock_character_three.value, world.options.unlock_character_four.value]
|
||||
}
|
||||
patch.write_file("options.json", json.dumps(options_dict).encode("UTF-8"))
|
||||
|
||||
# Write level files
|
||||
for i, level in enumerate(level_locations.values()):
|
||||
locations: list[Location] = []
|
||||
for location in level:
|
||||
if location.name in world.disabled_locations:
|
||||
locations += [GLLocation(world.player, location.name, location.id)]
|
||||
else:
|
||||
locations += [world.get_location(location.name)]
|
||||
patch.write_file(f"level_{i}.json", json.dumps(locations_to_dict(locations)).encode("UTF-8"))
|
||||
|
||||
# Write boss items file
|
||||
boss_location_names = [
|
||||
"Dragon's Lair - Dragon Mirror Shard",
|
||||
"Yeti's Cavern - Yeti Mirror Shard",
|
||||
"Chimera's Keep - Chimera Mirror Shard",
|
||||
"Vat of the Plague Fiend - Plague Fiend Mirror Shard",
|
||||
"Altar of Skorne - Skorne's Mask",
|
||||
"Altar of Skorne - Skorne's Horns",
|
||||
"Altar of Skorne - Skorne's Left Gauntlet",
|
||||
"Altar of Skorne - Skorne's Right Gauntlet"
|
||||
]
|
||||
|
||||
boss_locations: list[Location] = []
|
||||
for location_name in boss_location_names:
|
||||
if location_name in world.disabled_locations:
|
||||
# Get location ID from locationName_to_data
|
||||
location_data = locationName_to_data.get(location_name)
|
||||
if location_data:
|
||||
boss_locations += [GLLocation(world.player, location_name, location_data.id)]
|
||||
else:
|
||||
boss_locations += [world.get_location(location_name)]
|
||||
|
||||
patch.write_file("boss_items.json", json.dumps(locations_to_dict(boss_locations)).encode("UTF-8"))
|
||||
|
||||
|
||||
def locations_to_dict(locations: list[Location]) -> dict[str, tuple]:
|
||||
return {location.name: (location.item.code, location.item.player) if location.item is not None else (0, 0) for
|
||||
location in locations}
|
||||
|
||||
|
||||
def patch_docks(data: LevelData) -> LevelData:
|
||||
data.stream.seek(0x19AC, 0)
|
||||
data.stream.write(bytes([0x3, 0x20, 0x0, 0x18, 0x1, 0x7C]))
|
||||
data.stream.seek(0x74, 0)
|
||||
data.stream.write(bytes([0x3, 0x0, 0x0, 0x1A, 0x1, 0x8]))
|
||||
return data
|
||||
|
||||
|
||||
def patch_camp(data: LevelData) -> LevelData:
|
||||
data.stream.seek(0x1B74, 0)
|
||||
data.stream.write(bytes([0xFE, 0xE9, 0x0, 0x3B, 0xFF, 0xDC, 0x0, 0x10]))
|
||||
data.stream.seek(0x1B64, 0)
|
||||
data.stream.write(bytes([0xFE, 0x84, 0x0, 0x3D, 0xFF, 0xE0, 0xFF, 0xF0]))
|
||||
return data
|
||||
|
||||
|
||||
def patch_trenches(data: LevelData) -> LevelData:
|
||||
data.stream.seek(0xD4, 0)
|
||||
data.stream.write(bytes([0xFB, 0x68, 0x0, 0x82]))
|
||||
return data
|
||||
|
||||
|
||||
# Zlib decompression with wbits set to -15
|
||||
def zdec(data):
|
||||
decomp = zlib.decompressobj(-zlib.MAX_WBITS)
|
||||
output = bytearray()
|
||||
for i in range(0, len(data), 256):
|
||||
output.extend(decomp.decompress(data[i: i + 256]))
|
||||
output.extend(decomp.flush())
|
||||
return output
|
||||
|
||||
|
||||
# Zlib compression with compression set to max and wbits set to -15
|
||||
def zenc(data):
|
||||
compress = zlib.compressobj(zlib.Z_BEST_COMPRESSION, zlib.DEFLATED, -zlib.MAX_WBITS)
|
||||
output = bytearray()
|
||||
for i in range(0, len(data), 256):
|
||||
output.extend(compress.compress(data[i: i + 256]))
|
||||
output.extend(compress.flush())
|
||||
return output
|
||||
|
||||
|
||||
# Create a LevelData object from raw decompressed bytes of a level
|
||||
def get_level_data(stream: io.BytesIO, size: int, level: int = 0) -> tuple[io.BytesIO, LevelData]:
|
||||
data = LevelData()
|
||||
data.stream = io.BytesIO(zdec(stream.read(size)))
|
||||
if level == 17:
|
||||
data = patch_docks(data)
|
||||
if level == 18:
|
||||
data = patch_camp(data)
|
||||
if level == 24:
|
||||
data = patch_trenches(data)
|
||||
data.header = bytearray(data.stream.read(0x5C))
|
||||
data.stream.seek(0)
|
||||
data.item_addr = int.from_bytes(data.stream.read(4), "big")
|
||||
data.spawner_addr = int.from_bytes(data.stream.read(4), "big")
|
||||
data.stream.seek(4, 1)
|
||||
data.obj_addr = int.from_bytes(data.stream.read(4), "big")
|
||||
data.end_addr = int.from_bytes(data.stream.read(4), "big")
|
||||
data.portal_addr = int.from_bytes(data.stream.read(4), "big")
|
||||
data.chest_addr = int.from_bytes(data.stream.read(4), "big")
|
||||
data.end_addr2 = int.from_bytes(data.stream.read(4), "big")
|
||||
data.end_addr3 = int.from_bytes(data.stream.read(4), "big")
|
||||
data.stream.seek(data.item_addr)
|
||||
for i in range(data.stream.tell(), data.spawner_addr, 12):
|
||||
data.stream.seek(i)
|
||||
data.items += [bytearray(data.stream.read(12))]
|
||||
for i in range(data.stream.tell(), data.obj_addr, 16):
|
||||
data.stream.seek(i)
|
||||
data.spawners += [bytearray(data.stream.read(16))]
|
||||
for i in range(data.stream.tell(), data.chest_addr, 24):
|
||||
data.stream.seek(i)
|
||||
data.objects += [bytearray(data.stream.read(24))]
|
||||
for i in range(data.stream.tell(), data.end_addr, 16):
|
||||
data.stream.seek(i)
|
||||
data.chests += [bytearray(data.stream.read(16))]
|
||||
data.end = data.stream.read()
|
||||
return stream, data
|
||||
|
||||
|
||||
# Format a LevelData object back into a bytes object
|
||||
# Format is header, items, spawners, objects, barrels/chests, then traps.
|
||||
def level_data_reformat(data: LevelData) -> bytes:
|
||||
stream = io.BytesIO()
|
||||
obelisk_offset = 24 * (
|
||||
data.items_replaced_by_obelisks + data.chests_replaced_by_obelisks - data.obelisks_replaced_by_items)
|
||||
item_offset = 12 * (
|
||||
data.chests_replaced_by_items + data.obelisks_replaced_by_items - data.items_replaced_by_obelisks - data.items_replaced_by_spawners)
|
||||
chest_offset = 16 * (
|
||||
data.chests_replaced_by_obelisks + data.chests_replaced_by_items + data.chests_replaced_by_spawners)
|
||||
spawner_offset = 16 * (data.items_replaced_by_spawners + data.chests_replaced_by_spawners)
|
||||
|
||||
stream.write(int.to_bytes(0x5C, 4, "big"))
|
||||
stream.write(int.to_bytes(data.spawner_addr + item_offset, 4, "big"))
|
||||
stream.write(int.to_bytes(data.spawner_addr + item_offset, 4, "big"))
|
||||
stream.write(int.to_bytes(data.obj_addr + item_offset + spawner_offset, 4, "big"))
|
||||
stream.write(int.to_bytes(data.end_addr + item_offset + spawner_offset + obelisk_offset - chest_offset, 4, "big"))
|
||||
stream.write(
|
||||
int.to_bytes(data.portal_addr + item_offset + spawner_offset + obelisk_offset - chest_offset, 4, "big"))
|
||||
stream.write(int.to_bytes(data.chest_addr + item_offset + spawner_offset + obelisk_offset, 4, "big"))
|
||||
stream.write(int.to_bytes(data.end_addr2 + item_offset + spawner_offset + obelisk_offset - chest_offset, 4, "big"))
|
||||
stream.write(int.to_bytes(data.end_addr3 + item_offset + spawner_offset + obelisk_offset - chest_offset, 4, "big"))
|
||||
# Counts are 2 bytes each, big-endian
|
||||
stream.write(len(data.items).to_bytes(2, "big"))
|
||||
stream.write(bytes([0x0, 0x0]))
|
||||
stream.write(len(data.spawners).to_bytes(2, "big"))
|
||||
stream.write(len(data.objects).to_bytes(2, "big"))
|
||||
data.stream.seek(stream.tell())
|
||||
temp = bytearray(data.stream.read(48))
|
||||
temp[7] = len(data.chests)
|
||||
stream.write(temp)
|
||||
for item in data.items + data.spawners + data.objects + data.chests:
|
||||
stream.write(bytes(item))
|
||||
stream.write(data.end)
|
||||
return stream.getvalue()
|
||||
|
||||
|
||||
def chest_barrel(name: str):
|
||||
return "Chest" in name or ("Barrel" in name and "Barrel of Gold" not in name)
|
||||
@@ -0,0 +1,62 @@
|
||||
import typing
|
||||
|
||||
from BaseClasses import CollectionRule
|
||||
from worlds.generic.Rules import add_rule, forbid_item
|
||||
|
||||
from .Data import difficulty_lambda, level_locations, obelisks, boss_regions, excluded_levels, spawner_trap_ids, \
|
||||
difficulty_lambda_no_portal
|
||||
from .Items import items_by_id
|
||||
from .Locations import get_locations_by_tags
|
||||
from .Options import Goal
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from . import GauntletLegendsWorld
|
||||
|
||||
|
||||
def set_rules(world: "GauntletLegendsWorld"):
|
||||
for location in get_locations_by_tags("no_obelisks") + (get_locations_by_tags("obelisk") if world.options.obelisks else []):
|
||||
for item in obelisks:
|
||||
if location.name not in world.disabled_locations:
|
||||
forbid_item(world.get_location(location.name), item, world.player)
|
||||
|
||||
for location in get_locations_by_tags("no_spawner"):
|
||||
for item in spawner_trap_ids:
|
||||
if location.name not in world.disabled_locations:
|
||||
forbid_item(world.get_location(location.name), items_by_id[item].item_name, world.player)
|
||||
|
||||
if not world.options.instant_max:
|
||||
for level_id, locations in level_locations.items():
|
||||
for location in locations:
|
||||
if location.difficulty > 1:
|
||||
if location.name not in world.disabled_locations:
|
||||
if location.name not in world.disabled_locations:
|
||||
level_id_ = level_id >> 4
|
||||
difficulty = location.difficulty - 1
|
||||
if world.options.portals:
|
||||
expected_count = difficulty_lambda[level_id_][difficulty] - (
|
||||
len(world.excluded_regions) * 4)
|
||||
else:
|
||||
expected_count = difficulty_lambda_no_portal[level_id_][difficulty] - (
|
||||
len(world.excluded_regions) * 4)
|
||||
expected_count = max(expected_count, 0)
|
||||
add_rule(
|
||||
world.get_location(location.name),
|
||||
lambda state, expected_count_=expected_count: state.has("progression", world.player,
|
||||
expected_count)
|
||||
)
|
||||
|
||||
|
||||
def goal_conditions(world: "GauntletLegendsWorld") -> CollectionRule:
|
||||
if world.options.goal == Goal.option_defeat_skorne:
|
||||
return lambda state: state.can_reach("Gates of the Underworld", "Region", world.player)
|
||||
|
||||
eligible_boss_regions = [
|
||||
boss for boss in boss_regions
|
||||
if boss not in [
|
||||
level for region, levels in excluded_levels.items()
|
||||
if region in world.excluded_regions for level in levels
|
||||
]
|
||||
]
|
||||
needed_boss_count = world.options.boss_goal_count.value
|
||||
|
||||
return lambda state: sum(state.can_reach_region(boss, world.player) for boss in eligible_boss_regions) >= needed_boss_count
|
||||
@@ -0,0 +1,346 @@
|
||||
import os
|
||||
|
||||
import settings
|
||||
|
||||
from BaseClasses import ItemClassification, Tutorial, Item, CollectionState
|
||||
from Fill import fast_fill
|
||||
from typing import ClassVar
|
||||
|
||||
from worlds.AutoWorld import WebWorld, World
|
||||
|
||||
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, launch
|
||||
from .Data import obelisks, mirror_shards, portals, excluded_portals, \
|
||||
excluded_obelisks, level_locations
|
||||
from .Items import GLItem, item_table, item_list, gauntlet_item_name_groups
|
||||
from .Locations import LocationData, all_locations, location_table, get_locations_by_tags, locationName_to_data
|
||||
from .Options import GLOptions, IncludedAreas, IncludedTraps
|
||||
from .Regions import connect_regions, create_regions
|
||||
from .Rom import GLProcedurePatch, write_files
|
||||
from .Rules import set_rules, goal_conditions
|
||||
|
||||
|
||||
def launch_client(*args):
|
||||
from .GauntletLegendsClient import launch as launch_glclient
|
||||
launch(launch_glclient, name="GauntletLegendsClient", args=args)
|
||||
|
||||
|
||||
components.append(
|
||||
Component(
|
||||
"Gauntlet Legends Client",
|
||||
func=launch_client,
|
||||
component_type=Type.CLIENT,
|
||||
file_identifier=SuffixIdentifier(".apgl"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class GauntletLegendsWebWorld(WebWorld):
|
||||
theme = "partyTime"
|
||||
tutorials = [
|
||||
Tutorial(
|
||||
tutorial_name="Setup Guide",
|
||||
description="A guide to playing Gauntlet Legends",
|
||||
language="English",
|
||||
file_name="setup_en.md",
|
||||
link="setup/en",
|
||||
authors=["jamesbrq"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class GLSettings(settings.Group):
|
||||
class RetorarchPath(settings.UserFolderPath):
|
||||
"""The location of your Retroarch folder"""
|
||||
description = "Retroarch Folder"
|
||||
|
||||
class RomFile(settings.UserFilePath):
|
||||
"""File name of the GL US rom"""
|
||||
copy_to = "Gauntlet Legends (U) [!].z64"
|
||||
description = "Gauntlet Legends ROM File"
|
||||
md5s = ["9cb963e8b71f18568f78ec1af120362e"]
|
||||
|
||||
retroarch_path: RetorarchPath = RetorarchPath(None)
|
||||
rom_file: RomFile = RomFile(RomFile.copy_to)
|
||||
rom_start: bool = True
|
||||
|
||||
|
||||
class GauntletLegendsWorld(World):
|
||||
"""
|
||||
Adventure through the 5 realms to collect 13 runestones
|
||||
and defeat the evil skorne. Treasure, enemies, and death
|
||||
awaits.
|
||||
"""
|
||||
|
||||
game = "Gauntlet Legends"
|
||||
web = GauntletLegendsWebWorld()
|
||||
options_dataclass = GLOptions
|
||||
options: GLOptions
|
||||
settings: ClassVar[GLSettings]
|
||||
item_name_groups = gauntlet_item_name_groups
|
||||
item_name_to_id = {name: data.id for name, data in item_table.items()}
|
||||
location_name_to_id = {loc_data.name: loc_data.id for loc_data in all_locations}
|
||||
excluded_regions: set
|
||||
items: list[Item]
|
||||
unlockable: set
|
||||
|
||||
disabled_locations: set[str]
|
||||
|
||||
def generate_early(self) -> None:
|
||||
self.disabled_locations = set()
|
||||
self.excluded_regions = set()
|
||||
self.unlockable = set()
|
||||
self.items = []
|
||||
|
||||
def create_regions(self) -> None:
|
||||
if self.options.chests_barrels == "none":
|
||||
self.disabled_locations.update([
|
||||
location.name
|
||||
for location in all_locations
|
||||
if "Chest" in location.name or ("Barrel" in location.name and "Barrel of Gold" not in location.name)
|
||||
])
|
||||
elif self.options.chests_barrels == "all_chests":
|
||||
self.disabled_locations.update([
|
||||
location.name
|
||||
for location in all_locations
|
||||
if "Barrel" in location.name and "Barrel of Gold" not in location.name
|
||||
])
|
||||
elif self.options.chests_barrels == "all_barrels":
|
||||
self.disabled_locations.update([location.name for location in all_locations if "Chest" in location.name])
|
||||
|
||||
self.disabled_locations.update([location.name for location in all_locations
|
||||
if location.difficulty > self.options.max_difficulty])
|
||||
self.excluded_regions.update([region for region in IncludedAreas.valid_keys if region not in self.options.included_areas.value])
|
||||
self.options.boss_goal_count.value = min(self.options.boss_goal_count.value,
|
||||
6 - len([region for region in self.excluded_regions if region != "Battlefield"]))
|
||||
|
||||
create_regions(self)
|
||||
connect_regions(self)
|
||||
if not self.options.infinite_keys:
|
||||
self.lock_item("Valley of Fire - Key 1", "Key")
|
||||
self.lock_item("Valley of Fire - Key 5", "Key")
|
||||
|
||||
if not self.options.obelisks:
|
||||
self.lock_item("Valley of Fire - Obelisk", "Valley of Fire Obelisk")
|
||||
self.lock_item("Dagger Peak - Obelisk", "Dagger Peak Obelisk")
|
||||
self.lock_item("Cliffs of Desolation - Obelisk", "Cliffs of Desolation Obelisk")
|
||||
self.lock_item("Castle Courtyard - Obelisk", "Castle Courtyard Obelisk")
|
||||
self.lock_item("Dungeon of Torment - Obelisk", "Dungeon of Torment Obelisk")
|
||||
self.lock_item("Poisoned Fields - Obelisk", "Poisoned Fields Obelisk")
|
||||
self.lock_item("Haunted Cemetery - Obelisk", "Haunted Cemetery Obelisk")
|
||||
|
||||
if not self.options.mirror_shards:
|
||||
self.lock_item("Dragon's Lair - Dragon Mirror Shard", "Dragon Mirror Shard")
|
||||
self.lock_item("Chimera's Keep - Chimera Mirror Shard", "Chimera Mirror Shard")
|
||||
self.lock_item("Vat of the Plague Fiend - Plague Fiend Mirror Shard", "Plague Fiend Mirror Shard")
|
||||
self.lock_item("Yeti's Cavern - Yeti Mirror Shard", "Yeti Mirror Shard")
|
||||
|
||||
def fill_slot_data(self) -> dict:
|
||||
characters = [
|
||||
self.options.unlock_character_one.value,
|
||||
self.options.unlock_character_two.value,
|
||||
self.options.unlock_character_three.value,
|
||||
self.options.unlock_character_four.value,
|
||||
]
|
||||
chests_barrels = self.options.chests_barrels.value
|
||||
return {
|
||||
"chests": int(chests_barrels == 3 or chests_barrels == 1),
|
||||
"barrels": int(chests_barrels == 3 or chests_barrels == 2),
|
||||
"speed": self.options.permanent_speed.value,
|
||||
"keys": self.options.infinite_keys.value,
|
||||
"characters": characters,
|
||||
"max": self.options.max_difficulty.value,
|
||||
"instant_max": self.options.instant_max.value,
|
||||
"death_link": self.options.death_link.value,
|
||||
"portals": self.options.portals.value,
|
||||
"included_areas": [area for area in IncludedAreas.valid_keys if area in self.options.included_areas.value],
|
||||
"mirror_shards": self.options.mirror_shards.value,
|
||||
"obelisks": self.options.obelisks.value,
|
||||
"goal": self.options.goal.value,
|
||||
"boss_goal_count": self.options.boss_goal_count.value,
|
||||
}
|
||||
|
||||
def create_items(self) -> None:
|
||||
# First add in all progression and useful items
|
||||
required_items = []
|
||||
precollected = [item for item in item_list if item.item_name in [item.name for item in self.multiworld.precollected_items[self.player]]]
|
||||
skipped_items = set()
|
||||
items_required_count = len(self.multiworld.get_unfilled_locations(self.player))
|
||||
if self.options.infinite_keys:
|
||||
skipped_items.add("Key")
|
||||
if self.options.permanent_speed:
|
||||
skipped_items.add("Speed Boots")
|
||||
skipped_items.update([item for item in IncludedTraps.valid_keys if item not in self.options.included_traps.value])
|
||||
if not self.options.obelisks:
|
||||
skipped_items.update([obelisk for obelisk in obelisks if obelisk not in self.unlockable])
|
||||
if not self.options.mirror_shards:
|
||||
skipped_items.update([shard for shard in mirror_shards if shard not in self.unlockable])
|
||||
if not self.options.portals:
|
||||
skipped_items.update([item for item in portals.keys()])
|
||||
if len(self.excluded_regions) > 0:
|
||||
for region in self.excluded_regions:
|
||||
skipped_items.update(excluded_portals.get(region, []))
|
||||
skipped_items.update(excluded_obelisks.get(region, []))
|
||||
for item in [item_ for item_ in item_list
|
||||
if (ItemClassification.progression in item_.progression
|
||||
or ItemClassification.useful in item_.progression)
|
||||
and item_.item_name not in precollected
|
||||
and item_.item_name not in skipped_items]:
|
||||
freq = item.frequency
|
||||
required_items += [item.item_name for _ in range(freq)]
|
||||
items_required_count -= freq
|
||||
|
||||
self.multiworld.itempool += [self.create_item(item_name) for item_name in required_items]
|
||||
|
||||
# Then, get a random amount of fillers until we have as many items as we have locations
|
||||
filler_items = []
|
||||
for item in [item_ for item_ in item_list
|
||||
if ItemClassification.progression not in item_.progression
|
||||
and ItemClassification.useful not in item_.progression
|
||||
and item_.item_name not in skipped_items]:
|
||||
|
||||
freq = item.frequency
|
||||
if item.item_name == "Anti-Death Halo" and "Death" not in skipped_items and self.options.traps_frequency.value >= 30:
|
||||
freq *= 2
|
||||
|
||||
filler_items += [item.item_name for _ in range(freq)]
|
||||
self.random.shuffle(filler_items)
|
||||
|
||||
if len(self.options.included_traps.value) != 0:
|
||||
traps_cap = items_required_count - len(
|
||||
[location for location in self.multiworld.get_unfilled_locations(self.player) if
|
||||
"no_spawner" in locationName_to_data[location.name].tags])
|
||||
traps_frequency = int(len(self.get_locations()) * (self.options.traps_frequency / 100)) // len(self.options.included_traps.value)
|
||||
traps_frequency = min(traps_frequency, traps_cap // len(self.options.included_traps.value))
|
||||
for item in self.options.included_traps.value:
|
||||
if items_required_count == 0:
|
||||
break
|
||||
self.multiworld.itempool += [self.create_item(item) for _ in range(min(traps_frequency, items_required_count))]
|
||||
items_required_count = max(items_required_count - traps_frequency, 0)
|
||||
|
||||
for i in range(items_required_count):
|
||||
if i < int(items_required_count * (self.options.local_filler_frequency / 100)):
|
||||
if self.multiworld.players > 1:
|
||||
self.items.append(self.create_item(filler_items.pop()))
|
||||
else:
|
||||
self.multiworld.itempool.append(self.create_item(filler_items.pop()))
|
||||
else:
|
||||
self.multiworld.itempool.append(self.create_item(filler_items.pop()))
|
||||
|
||||
|
||||
def set_rules(self) -> None:
|
||||
set_rules(self)
|
||||
self.multiworld.completion_condition[self.player] = goal_conditions(self)
|
||||
|
||||
def pre_fill(self) -> None:
|
||||
local_item_count = len(self.items)
|
||||
unfilled_locations = self.multiworld.get_unfilled_locations(self.player)
|
||||
unfilled_names = {loc.name for loc in unfilled_locations}
|
||||
skipped = get_locations_by_tags("skipped_local")
|
||||
no_obelisk_locs = get_locations_by_tags("no_obelisks")
|
||||
|
||||
# Group available locations by level
|
||||
level_locs = {}
|
||||
for level_id, level in level_locations.items():
|
||||
if level_id & 0x8 == 0x8:
|
||||
continue
|
||||
available = [loc for loc in level if loc.name in unfilled_names and loc.name not in skipped]
|
||||
if available:
|
||||
level_locs[level_id] = sorted(available, key=lambda l: l.difficulty, reverse=True)
|
||||
|
||||
all_available = [loc for locs in level_locs.values() for loc in locs]
|
||||
|
||||
# Calculate target per level, ensuring we don't fill entire regions
|
||||
target_per_level = (local_item_count // len(level_locs)) + 2 if level_locs else 0
|
||||
local_locations = []
|
||||
|
||||
for level_id, locs in level_locs.items():
|
||||
# Leave at least 1 location unfilled per region if possible
|
||||
max_from_level = max(1, len(locs) - 1) if len(locs) > 1 else len(locs)
|
||||
count = min(target_per_level, max_from_level)
|
||||
|
||||
# Split into harder/easier halves
|
||||
mid = len(locs) // 2
|
||||
harder, easier = locs[:mid], locs[mid:]
|
||||
self.random.shuffle(harder)
|
||||
self.random.shuffle(easier)
|
||||
|
||||
# Take 60% from harder, 40% from easier
|
||||
harder_count = int(count * 0.6)
|
||||
easier_count = count - harder_count
|
||||
local_locations.extend(self.get_location(l.name) for l in harder[:harder_count])
|
||||
local_locations.extend(self.get_location(l.name) for l in easier[:easier_count])
|
||||
|
||||
# If still need more, pull from levels with most remaining availability
|
||||
if len(local_locations) < local_item_count:
|
||||
used_names = {loc.name for loc in local_locations}
|
||||
remaining = []
|
||||
for level_id, locs in level_locs.items():
|
||||
available = [l for l in locs if l.name not in used_names]
|
||||
for loc in available:
|
||||
remaining.append((loc, len(available)))
|
||||
|
||||
remaining.sort(key=lambda x: x[1], reverse=True)
|
||||
needed = local_item_count - len(local_locations)
|
||||
local_locations.extend(self.get_location(loc.name) for loc, _ in remaining[:needed])
|
||||
|
||||
# Ensure obelisk accessibility
|
||||
used_names = {loc.name for loc in local_locations}
|
||||
remaining_unfilled = [loc.name for loc in all_available if loc.name not in used_names]
|
||||
|
||||
if remaining_unfilled and all(name in no_obelisk_locs for name in remaining_unfilled):
|
||||
chosen_no_obelisk = [loc for loc in local_locations if loc.name in no_obelisk_locs]
|
||||
unchosen_obelisk_ok = [loc for loc in all_available
|
||||
if loc.name not in used_names and loc.name not in no_obelisk_locs]
|
||||
|
||||
swap_count = min(len(chosen_no_obelisk) // 2, len(unchosen_obelisk_ok))
|
||||
if swap_count > 0:
|
||||
self.random.shuffle(chosen_no_obelisk)
|
||||
self.random.shuffle(unchosen_obelisk_ok)
|
||||
|
||||
for i in range(swap_count):
|
||||
local_locations.remove(chosen_no_obelisk[i])
|
||||
local_locations.append(self.get_location(unchosen_obelisk_ok[i].name))
|
||||
|
||||
# Final shuffle and fill
|
||||
local_locations = local_locations[:local_item_count]
|
||||
self.random.shuffle(self.items)
|
||||
self.random.shuffle(local_locations)
|
||||
fast_fill(self.multiworld, self.items, local_locations)
|
||||
|
||||
def create_item(self, name: str) -> GLItem:
|
||||
item = item_table[name]
|
||||
return GLItem(item.item_name, item.progression, item.id, self.player)
|
||||
|
||||
def lock_item(self, location: str, item_name: str) -> None:
|
||||
item = self.create_item(item_name)
|
||||
if location in self.disabled_locations:
|
||||
self.unlockable.update({item_name})
|
||||
return
|
||||
self.get_location(location).place_locked_item(item)
|
||||
|
||||
def collect(self, state: "CollectionState", item: "Item") -> bool:
|
||||
change = super().collect(state, item)
|
||||
if change and "Runestone" in item.name:
|
||||
state.prog_items[item.player]["stones"] += 1
|
||||
if change and (ItemClassification.progression in item.classification):
|
||||
state.prog_items[item.player]["progression"] += 1
|
||||
return change
|
||||
|
||||
def remove(self, state: "CollectionState", item: "Item") -> bool:
|
||||
change = super().remove(state, item)
|
||||
if change and "Runestone" in item.name:
|
||||
state.prog_items[item.player]["stones"] -= 1
|
||||
if change and (ItemClassification.progression in item.classification):
|
||||
state.prog_items[item.player]["progression"] -= 1
|
||||
return change
|
||||
|
||||
def get_filler_item_name(self) -> str:
|
||||
return self.random.choice(list(filter(lambda item: item.progression == ItemClassification.filler, item_list))).item_name
|
||||
|
||||
def generate_output(self, output_directory: str) -> None:
|
||||
patch = GLProcedurePatch(player=self.player, player_name=self.player_name)
|
||||
write_files(self, patch)
|
||||
rom_path = os.path.join(
|
||||
output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}{patch.patch_file_ending}",
|
||||
)
|
||||
patch.write(rom_path)
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"game": "Gauntlet Legends",
|
||||
"authors": [ "jamesbrq" ],
|
||||
"minimum_ap_version": "0.6.6",
|
||||
"world_version": "2.1.7"
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,55 @@
|
||||
# Gauntlet Legends
|
||||
|
||||
## Where is the options page?
|
||||
|
||||
The [player options page for this game](../player-options) contains all the options you need to configure and
|
||||
export a config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
Items which the player would normally acquire throughout the game have been moved around. Logic remains, so the game is
|
||||
always able to be completed, but because of the item shuffle, the player may need to access certain areas before they
|
||||
would in the vanilla game.
|
||||
|
||||
## What is the goal of Gauntlet Legends when randomized?
|
||||
|
||||
Defeat Skorne in the Underworld. This requires gathering all 13 Runestones to gain access to the Underworld, and being strong enough to beat Skorne.
|
||||
|
||||
Along the way, players will need to activate Obelisks to unlock new areas for them to explore.
|
||||
They may also need to collect Mirror Shards, which will unlock a 5th area to explore as well as the Cathedral level.
|
||||
|
||||
An alternate goal is to defeat a certain number of unique bosses at the end of each area, which can be configured in the options.
|
||||
|
||||
## What items and locations can get shuffled?
|
||||
|
||||
Locations in which items can be found:
|
||||
- All in-stage items
|
||||
- All Chests
|
||||
- All Barrels
|
||||
- All Runestones
|
||||
- All Obelisks
|
||||
- All Mirror Shards
|
||||
|
||||
Items that can be placed into locations:
|
||||
- Keys
|
||||
- Potions
|
||||
- All consumable items
|
||||
- Runestones
|
||||
- Obelisks
|
||||
- Mirror Shards
|
||||
- Level Portals
|
||||
- Death
|
||||
- Golems
|
||||
- Goblin Shooters, Bomb Throwers, and Bomb Runners
|
||||
|
||||
The randomizer also includes new portal items, which can be used as an alternate way to unlock new levels,
|
||||
and add an extra layer of progression to the game.
|
||||
|
||||
## What does another world's item look like in Gauntlet Legends?
|
||||
|
||||
Items will show up as the Archipelago Logo, both as items and in chests or barrels.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
|
||||
Items will be placed directly into the players inventory.
|
||||
If the player either leaves a stage midway or dies, the items they received mid-level will be given back to them in the lobby.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Setup Guide for Gauntlet Legends in Archipelago
|
||||
|
||||
## Required Software
|
||||
|
||||
- Retroarch: [Standalone](https://www.retroarch.com/?page=platforms), [Steam](https://store.steampowered.com/app/1118310/RetroArch/)
|
||||
- The built-in Gauntlet Legends Client, which can be installed [here](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- A US copy of Gauntlet Legends for the N64
|
||||
|
||||
## Configuring Retroarch
|
||||
|
||||
### Enabling Network Commands
|
||||
|
||||
You must go to Settings -> User Interface and turn the Show Advanced Settings to On.
|
||||
Then in Settings -> Network, you must also turn On Network Commands.
|
||||
Leave the port as the default.
|
||||
|
||||
### Selecting a Core
|
||||
|
||||
When selecting a core, make sure to select Mupen64-plus-next core.
|
||||
|
||||
## Configuring your YAML file
|
||||
|
||||
### What is a YAML file and why do I need one?
|
||||
|
||||
Your YAML file contains a set of configuration options which provide the generator with information about how it should
|
||||
generate your game. Each player of a multiworld will provide their own YAML file. This setup allows each player to enjoy
|
||||
an experience customized for their taste, and different players in the same multiworld can all have different options.
|
||||
|
||||
### Where do I get a YAML file?
|
||||
|
||||
You can customize your options by visiting the
|
||||
[Gauntlet Legends Options Page](/games/Gauntlet%20Legends/player-options)
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
### Obtain your N64 patch file
|
||||
|
||||
When you join a multiworld game, you will be asked to provide your YAML file to whoever is hosting.
|
||||
The host generating does not need a copy of Gauntlet Legends to be able to generate with a Gauntlet Legends yaml.
|
||||
|
||||
Once that is done, the host will provide you with either a link to download your data file,
|
||||
or with a zip file containing everyone's data files. Your data file should have a `.apgl` extension.
|
||||
|
||||
|
||||
Double-click on your `.apgl` file to start the ROM patch process. Once the process is finished, the client will be started automatically.
|
||||
|
||||
### Connect to the Multiserver
|
||||
|
||||
This game uses its own custom client, named Gauntlet Legends Client.
|
||||
Retroarch is only emulator this client will accept.
|
||||
|
||||
Once both the client and the emulator are started, you must connect them. Once your ROM is open in Retroarch,
|
||||
as long as the client is open they will be connected to each other.
|
||||
|
||||
To connect the client to the multiserver simply put `<address>:<port>` on the textfield on top and press enter (if the
|
||||
server uses password, type in the bottom textfield `/connect <address>:<port> [password]`).
|
||||
|
||||
The client will prompt you for your slot name, once you enter and submit it, you will be connected to the lobby.
|
||||
@@ -0,0 +1,574 @@
|
||||
[
|
||||
{
|
||||
"id": 77780000,
|
||||
"item_name": "Key",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0000",
|
||||
"frequency": 700
|
||||
},
|
||||
{
|
||||
"id": 77780001,
|
||||
"item_name": "Lightning Potion",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0101",
|
||||
"frequency": 30
|
||||
},
|
||||
{
|
||||
"id": 77780002,
|
||||
"item_name": "Light Potion",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0102",
|
||||
"frequency": 30
|
||||
},
|
||||
{
|
||||
"id": 77780003,
|
||||
"item_name": "Acid Potion",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0103",
|
||||
"frequency": 30
|
||||
},
|
||||
{
|
||||
"id": 77780004,
|
||||
"item_name": "Fire Potion",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0104",
|
||||
"frequency": 30
|
||||
},
|
||||
{
|
||||
"id": 77780005,
|
||||
"item_name": "Acid Breath",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0212",
|
||||
"frequency": 20
|
||||
},
|
||||
{
|
||||
"id": 77780006,
|
||||
"item_name": "Lightning Breath",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0211",
|
||||
"frequency": 20
|
||||
},
|
||||
{
|
||||
"id": 77780007,
|
||||
"item_name": "Fire Breath",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0210",
|
||||
"frequency": 20
|
||||
},
|
||||
{
|
||||
"id": 77780008,
|
||||
"item_name": "Light Amulet",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0209",
|
||||
"frequency": 20
|
||||
},
|
||||
{
|
||||
"id": 77780009,
|
||||
"item_name": "Acid Amulet",
|
||||
"progression": 0,
|
||||
"rom_id": "0x020A",
|
||||
"frequency": 20
|
||||
},
|
||||
{
|
||||
"id": 77780010,
|
||||
"item_name": "Lightning Amulet",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0208",
|
||||
"frequency": 20
|
||||
},
|
||||
{
|
||||
"id": 77780011,
|
||||
"item_name": "Fire Amulet",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0207",
|
||||
"frequency": 20
|
||||
},
|
||||
{
|
||||
"id": 77780012,
|
||||
"item_name": "Lightning Shield",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0218",
|
||||
"frequency": 15
|
||||
},
|
||||
{
|
||||
"id": 77780013,
|
||||
"item_name": "Fire Shield",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0217",
|
||||
"frequency": 15
|
||||
},
|
||||
{
|
||||
"id": 77780014,
|
||||
"item_name": "Invisibility",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0205",
|
||||
"frequency": 15
|
||||
},
|
||||
{
|
||||
"id": 77780015,
|
||||
"item_name": "Levitate",
|
||||
"progression": 0,
|
||||
"rom_id": "0x021C",
|
||||
"frequency": 10
|
||||
},
|
||||
{
|
||||
"id": 77780016,
|
||||
"item_name": "Speed Boots",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0200",
|
||||
"frequency": 30
|
||||
},
|
||||
{
|
||||
"id": 77780017,
|
||||
"item_name": "3-Way Shot",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0204",
|
||||
"frequency": 15
|
||||
},
|
||||
{
|
||||
"id": 77780018,
|
||||
"item_name": "5-Way Shot",
|
||||
"progression": 0,
|
||||
"rom_id": "0x020E",
|
||||
"frequency": 15
|
||||
},
|
||||
{
|
||||
"id": 77780019,
|
||||
"item_name": "Rapid Fire",
|
||||
"progression": 0,
|
||||
"rom_id": "0x021A",
|
||||
"frequency": 15
|
||||
},
|
||||
{
|
||||
"id": 77780020,
|
||||
"item_name": "Reflective Shot",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0202",
|
||||
"frequency": 10
|
||||
},
|
||||
{
|
||||
"id": 77780021,
|
||||
"item_name": "Reflective Shield",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0216",
|
||||
"frequency": 10
|
||||
},
|
||||
{
|
||||
"id": 77780022,
|
||||
"item_name": "Super Shot",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0203",
|
||||
"frequency": 15
|
||||
},
|
||||
{
|
||||
"id": 77780023,
|
||||
"item_name": "Timestop",
|
||||
"progression": 0,
|
||||
"rom_id": "0x020C",
|
||||
"frequency": 15
|
||||
},
|
||||
{
|
||||
"id": 77780024,
|
||||
"item_name": "Phoenix Familiar",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0213",
|
||||
"frequency": 20
|
||||
},
|
||||
{
|
||||
"id": 77780025,
|
||||
"item_name": "Growth",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0214",
|
||||
"frequency": 20
|
||||
},
|
||||
{
|
||||
"id": 77780026,
|
||||
"item_name": "Shrink",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0215",
|
||||
"frequency": 15
|
||||
},
|
||||
{
|
||||
"id": 77780027,
|
||||
"item_name": "Thunder Hammer",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0219",
|
||||
"frequency": 15
|
||||
},
|
||||
{
|
||||
"id": 77780028,
|
||||
"item_name": "Anti-Death Halo",
|
||||
"progression": 0,
|
||||
"rom_id": "0x020D",
|
||||
"frequency": 15
|
||||
},
|
||||
{
|
||||
"id": 77780029,
|
||||
"item_name": "Invulnerability",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0206",
|
||||
"frequency": 15
|
||||
},
|
||||
{
|
||||
"id": 77780030,
|
||||
"item_name": "Fruit",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0401",
|
||||
"frequency": 200
|
||||
},
|
||||
{
|
||||
"id": 77780031,
|
||||
"item_name": "Meat",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0403",
|
||||
"frequency": 150
|
||||
},
|
||||
{
|
||||
"id": 77780032,
|
||||
"item_name": "Runestone 1",
|
||||
"progression": 1,
|
||||
"rom_id": "0x1501"
|
||||
},
|
||||
{
|
||||
"id": 77780033,
|
||||
"item_name": "Runestone 2",
|
||||
"progression": 1,
|
||||
"rom_id": "0x1502"
|
||||
},
|
||||
{
|
||||
"id": 77780034,
|
||||
"item_name": "Runestone 3",
|
||||
"progression": 1,
|
||||
"rom_id": "0x1503"
|
||||
},
|
||||
{
|
||||
"id": 77780035,
|
||||
"item_name": "Runestone 4",
|
||||
"progression": 1,
|
||||
"rom_id": "0x1504"
|
||||
},
|
||||
{
|
||||
"id": 77780036,
|
||||
"item_name": "Runestone 5",
|
||||
"progression": 1,
|
||||
"rom_id": "0x1505"
|
||||
},
|
||||
{
|
||||
"id": 77780037,
|
||||
"item_name": "Runestone 6",
|
||||
"progression": 1,
|
||||
"rom_id": "0x1506"
|
||||
},
|
||||
{
|
||||
"id": 77780038,
|
||||
"item_name": "Runestone 7",
|
||||
"progression": 1,
|
||||
"rom_id": "0x1507"
|
||||
},
|
||||
{
|
||||
"id": 77780039,
|
||||
"item_name": "Runestone 8",
|
||||
"progression": 1,
|
||||
"rom_id": "0x1508"
|
||||
},
|
||||
{
|
||||
"id": 77780040,
|
||||
"item_name": "Runestone 9",
|
||||
"progression": 1,
|
||||
"rom_id": "0x1509"
|
||||
},
|
||||
{
|
||||
"id": 77780041,
|
||||
"item_name": "Runestone 10",
|
||||
"progression": 1,
|
||||
"rom_id": "0x150A"
|
||||
},
|
||||
{
|
||||
"id": 77780042,
|
||||
"item_name": "Runestone 11",
|
||||
"progression": 1,
|
||||
"rom_id": "0x150B"
|
||||
},
|
||||
{
|
||||
"id": 77780043,
|
||||
"item_name": "Runestone 12",
|
||||
"progression": 1,
|
||||
"rom_id": "0x150C"
|
||||
},
|
||||
{
|
||||
"id": 77780044,
|
||||
"item_name": "Runestone 13",
|
||||
"progression": 1,
|
||||
"rom_id": "0x150D"
|
||||
},
|
||||
{
|
||||
"id": 77780045,
|
||||
"item_name": "Dragon Mirror Shard",
|
||||
"progression": 1,
|
||||
"rom_id": "0x2B01"
|
||||
},
|
||||
{
|
||||
"id": 77780046,
|
||||
"item_name": "Yeti Mirror Shard",
|
||||
"progression": 1,
|
||||
"rom_id": "0x2B02"
|
||||
},
|
||||
{
|
||||
"id": 77780047,
|
||||
"item_name": "Chimera Mirror Shard",
|
||||
"progression": 1,
|
||||
"rom_id": "0x2B03"
|
||||
},
|
||||
{
|
||||
"id": 77780048,
|
||||
"item_name": "Plague Fiend Mirror Shard",
|
||||
"progression": 1,
|
||||
"rom_id": "0x2B04"
|
||||
},
|
||||
{
|
||||
"id": 77780049,
|
||||
"item_name": "Ice Axe of Untar",
|
||||
"progression": 2,
|
||||
"rom_id": "0x2901"
|
||||
},
|
||||
{
|
||||
"id": 77780050,
|
||||
"item_name": "Flame of Tarkana",
|
||||
"progression": 2,
|
||||
"rom_id": "0x2902"
|
||||
},
|
||||
{
|
||||
"id": 77780051,
|
||||
"item_name": "Scimitar of Decapitation",
|
||||
"progression": 2,
|
||||
"rom_id": "0x2903"
|
||||
},
|
||||
{
|
||||
"id": 77780052,
|
||||
"item_name": "Marker's Javelin",
|
||||
"progression": 2,
|
||||
"rom_id": "0x2904"
|
||||
},
|
||||
{
|
||||
"id": 77780053,
|
||||
"item_name": "Soul Savior",
|
||||
"progression": 2,
|
||||
"rom_id": "0x2905"
|
||||
},
|
||||
{
|
||||
"id": 77780054,
|
||||
"item_name": "Gold",
|
||||
"progression": 0,
|
||||
"rom_id": "0x0302",
|
||||
"frequency": 850
|
||||
},
|
||||
{
|
||||
"id": 77780055,
|
||||
"item_name": "Valley of Fire Obelisk",
|
||||
"progression": 1
|
||||
},
|
||||
{
|
||||
"id": 77780056,
|
||||
"item_name": "Dagger Peak Obelisk",
|
||||
"progression": 1
|
||||
},
|
||||
{
|
||||
"id": 77780057,
|
||||
"item_name": "Cliffs of Desolation Obelisk",
|
||||
"progression": 1
|
||||
},
|
||||
{
|
||||
"id": 77780058,
|
||||
"item_name": "Poisoned Fields Obelisk",
|
||||
"progression": 1
|
||||
},
|
||||
{
|
||||
"id": 77780059,
|
||||
"item_name": "Haunted Cemetery Obelisk",
|
||||
"progression": 1
|
||||
},
|
||||
{
|
||||
"id": 77780060,
|
||||
"item_name": "Castle Courtyard Obelisk",
|
||||
"progression": 1
|
||||
},
|
||||
{
|
||||
"id": 77780061,
|
||||
"item_name": "Dungeon of Torment Obelisk",
|
||||
"progression": 1
|
||||
},
|
||||
{
|
||||
"id": 77780062,
|
||||
"item_name": "Death",
|
||||
"progression": 4,
|
||||
"rom_id": "0x2101"
|
||||
},
|
||||
{
|
||||
"id": 77780063,
|
||||
"item_name": "Poison Fruit",
|
||||
"progression": 4,
|
||||
"rom_id": "0x0402"
|
||||
},
|
||||
{
|
||||
"id": 77780064,
|
||||
"item_name": "Skorne's Mask",
|
||||
"progression": 2,
|
||||
"rom_id": "0x2A01"
|
||||
},
|
||||
{
|
||||
"id": 77780065,
|
||||
"item_name": "Skorne's Horns",
|
||||
"progression": 2,
|
||||
"rom_id": "0x2A02"
|
||||
},
|
||||
{
|
||||
"id": 77780066,
|
||||
"item_name": "Skorne's Right Gauntlet",
|
||||
"progression": 2,
|
||||
"rom_id": "0x2A03"
|
||||
},
|
||||
{
|
||||
"id": 77780067,
|
||||
"item_name": "Skorne's Left Gauntlet",
|
||||
"progression": 2,
|
||||
"rom_id": "0x2A04"
|
||||
},
|
||||
{
|
||||
"id": 77780068,
|
||||
"item_name": "Portal to Dagger Peak",
|
||||
"progression": 1,
|
||||
"rom_id": "0x2709"
|
||||
},
|
||||
{
|
||||
"id": 77780069,
|
||||
"item_name": "Portal to Cliffs of Desolation",
|
||||
"progression": 1,
|
||||
"rom_id": "0x270A"
|
||||
},
|
||||
{
|
||||
"id": 77780070,
|
||||
"item_name": "Portal to Lost Cave",
|
||||
"progression": 1,
|
||||
"rom_id": "0x270B"
|
||||
},
|
||||
{
|
||||
"id": 77780071,
|
||||
"item_name": "Portal to Volcanic Caverns",
|
||||
"progression": 1,
|
||||
"rom_id": "0x270C"
|
||||
},
|
||||
{
|
||||
"id": 77780072,
|
||||
"item_name": "Portal to Dragon's Lair",
|
||||
"progression": 1,
|
||||
"rom_id": "0x270D"
|
||||
},
|
||||
{
|
||||
"id": 77780073,
|
||||
"item_name": "Portal to Dungeon of Torment",
|
||||
"progression": 1,
|
||||
"rom_id": "0x270E"
|
||||
},
|
||||
{
|
||||
"id": 77780074,
|
||||
"item_name": "Portal to Tower Armory",
|
||||
"progression": 1,
|
||||
"rom_id": "0x270F"
|
||||
},
|
||||
{
|
||||
"id": 77780075,
|
||||
"item_name": "Portal to Castle Treasury",
|
||||
"progression": 1,
|
||||
"rom_id": "0x2710"
|
||||
},
|
||||
{
|
||||
"id": 77780076,
|
||||
"item_name": "Portal to Chimera's Keep",
|
||||
"progression": 1,
|
||||
"rom_id": "0x2711"
|
||||
},
|
||||
{
|
||||
"id": 77780077,
|
||||
"item_name": "Portal to Haunted Cemetery",
|
||||
"progression": 1,
|
||||
"rom_id": "0x2712"
|
||||
},
|
||||
{
|
||||
"id": 77780078,
|
||||
"item_name": "Portal to Venomous Spire",
|
||||
"progression": 1,
|
||||
"rom_id": "0x2713"
|
||||
},
|
||||
{
|
||||
"id": 77780079,
|
||||
"item_name": "Portal to Toxic Air Ship",
|
||||
"progression": 1,
|
||||
"rom_id": "0x2714"
|
||||
},
|
||||
{
|
||||
"id": 77780080,
|
||||
"item_name": "Portal to Vat of the Plague Fiend",
|
||||
"progression": 1,
|
||||
"rom_id": "0x2715"
|
||||
},
|
||||
{
|
||||
"id": 77780081,
|
||||
"item_name": "Portal to Frozen Camp",
|
||||
"progression": 1,
|
||||
"rom_id": "0x2716"
|
||||
},
|
||||
{
|
||||
"id": 77780082,
|
||||
"item_name": "Portal to Crystal Mine",
|
||||
"progression": 1,
|
||||
"rom_id": "0x2717"
|
||||
},
|
||||
{
|
||||
"id": 77780083,
|
||||
"item_name": "Portal to Erupting Fissure",
|
||||
"progression": 1,
|
||||
"rom_id": "0x2718"
|
||||
},
|
||||
{
|
||||
"id": 77780084,
|
||||
"item_name": "Portal to Yeti's Cavern",
|
||||
"progression": 1,
|
||||
"rom_id": "0x2719"
|
||||
},
|
||||
{
|
||||
"id": 77780085,
|
||||
"item_name": "Portal to Fortified Towers",
|
||||
"progression": 1,
|
||||
"rom_id": "0x271A"
|
||||
},
|
||||
{
|
||||
"id": 77780086,
|
||||
"item_name": "Portal to Infernal Fortress",
|
||||
"progression": 1,
|
||||
"rom_id": "0x271B"
|
||||
},
|
||||
{
|
||||
"id": 77780087,
|
||||
"item_name": "Crossbow Shooter",
|
||||
"progression": 4,
|
||||
"rom_id": "0x05030411"
|
||||
},
|
||||
{
|
||||
"id": 77780088,
|
||||
"item_name": "Bomb Thrower",
|
||||
"progression": 4,
|
||||
"rom_id": "0x05030511"
|
||||
},
|
||||
{
|
||||
"id": 77780089,
|
||||
"item_name": "Bomb Runner",
|
||||
"progression": 4,
|
||||
"rom_id": "0x05030612"
|
||||
},
|
||||
{
|
||||
"id": 77780090,
|
||||
"item_name": "Golem",
|
||||
"progression": 4,
|
||||
"rom_id": "0x05080113"
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user