Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: Electro1512/MetroidAPrime
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v0.1.4
Choose a base ref
...
head repository: Electro1512/MetroidAPrime
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: main
Choose a head ref

Commits on Jun 27, 2024

  1. Copy the full SHA
    bb39edb View commit details
  2. Copy the full SHA
    787ba40 View commit details
  3. Copy the full SHA
    b745dbc View commit details
  4. Fix typo

    hesto2 committed Jun 27, 2024
    Copy the full SHA
    6de76de View commit details
  5. Update README.md and setup_en.md (#2)

    Update README.md
    Update setup_en.md
    Nystrata authored Jun 27, 2024
    Copy the full SHA
    cf7f245 View commit details
  6. Fix issue with hud default

    hesto2 committed Jun 27, 2024
    Copy the full SHA
    87cce3c View commit details
  7. Copy the full SHA
    420501e View commit details
  8. Copy the full SHA
    2ba1904 View commit details
  9. Add combat logic to end bosses

    hesto2 committed Jun 27, 2024
    Copy the full SHA
    6ce5793 View commit details
  10. Add combat logic to bosses

    hesto2 committed Jun 27, 2024
    Copy the full SHA
    a65ed50 View commit details
  11. Add combat logic for ghosts

    hesto2 committed Jun 27, 2024
    Copy the full SHA
    4610efd View commit details
  12. Fix logic for buckle up rooms

    hesto2 committed Jun 27, 2024
    Copy the full SHA
    0a3d4ab View commit details
  13. Add combat logic for labs

    hesto2 committed Jun 27, 2024
    Copy the full SHA
    714d203 View commit details
  14. Add mines combat logic

    hesto2 committed Jun 27, 2024
    Copy the full SHA
    7655a76 View commit details
  15. Copy the full SHA
    8f390ca View commit details

Commits on Jun 30, 2024

  1. Copy the full SHA
    bd03783 View commit details
  2. Copy the full SHA
    38b8bf8 View commit details
  3. Copy the full SHA
    a02e98a View commit details
  4. Copy the full SHA
    aaf4e79 View commit details
  5. fix typo

    hesto2 committed Jun 30, 2024
    Copy the full SHA
    2e19ba6 View commit details
  6. Fix issue with save station b restricted start due to combat logic, u…

    …pdate relative imports (some)
    hesto2 committed Jun 30, 2024
    Copy the full SHA
    38ab697 View commit details
  7. Remove relative imports

    hesto2 committed Jun 30, 2024
    Copy the full SHA
    90670ae View commit details
  8. Add flaahgra power bomb option

    hesto2 committed Jun 30, 2024
    Copy the full SHA
    5d5150f View commit details
  9. Copy the full SHA
    ad3fa4d View commit details

Commits on Jul 5, 2024

  1. Elevator rando (#7)

    * Add default mapping
    
    * Change random lib to correctly use world seed
    
    * Add elevator shuffle algorithm
    
    * Remove unused color for hud options
    
    * initial elevator rando
    
    * fix tower of light missing sjb
    
    * Add plando elevators
    
    * Fix issue with prime not requiring phazon suit
    
    * Add start room mapping option, better tooling for debug
    
    * Pre start room modification
    
    * Add logging to help with debugging failed seed gens
    
    * Add elevator data to start rooms
    
    * Add allow list
    
    * Fix twin fires tunnel etank req
    
    * add deny list support for elevators
    
    * Re add start room test
    
    * Add elevator rando tests
    
    * Add option to disable starting room bk prevention
    
    * Add tests for start room bk prevention
    
    * Fix issue with generated templates having incorrect formatting
    
    * Fix issue with generated yaml template
    
    * Fix missing power beam requirement for hall of the elders item
    
    * Fix missing boost requirement to go from lower great tree chamber to upper.
    
    * Add damage boosting as a trick for watery hall, transport tunnel b (magmoor), and phazon processing center to elite quarters
    hesto2 authored Jul 5, 2024
    Copy the full SHA
    c0f2c2d View commit details
  2. Fix tests

    hesto2 committed Jul 5, 2024
    Copy the full SHA
    f3a5eba View commit details
  3. Copy the full SHA
    c871b5c View commit details
  4. Fix yaml error

    hesto2 committed Jul 5, 2024
    Copy the full SHA
    3a033a8 View commit details
  5. Copy the full SHA
    27c05b0 View commit details
  6. fix message command

    hesto2 committed Jul 5, 2024
    Copy the full SHA
    28d53fb View commit details
  7. Copy the full SHA
    5a78fce View commit details
  8. Copy the full SHA
    178c6ef View commit details
  9. fix bloat issue

    hesto2 committed Jul 5, 2024
    Copy the full SHA
    8676056 View commit details

Commits on Jul 6, 2024

  1. Copy the full SHA
    bf9fb7b View commit details
  2. Shuffle scan visor

    hesto2 committed Jul 6, 2024
    Copy the full SHA
    7262a54 View commit details

Commits on Jul 8, 2024

  1. Copy the full SHA
    04396e7 View commit details
  2. Copy the full SHA
    391793a View commit details
  3. Copy the full SHA
    01efe9e View commit details
  4. Fix typo in yaml

    hesto2 committed Jul 8, 2024
    Copy the full SHA
    513807e View commit details
  5. Fix start_inventory and start_inventory_from_pool not working as expe…

    …cted for etanks, missiles and PB expansions
    hesto2 committed Jul 8, 2024
    Copy the full SHA
    f85090e View commit details
  6. Fix import order issue

    hesto2 committed Jul 8, 2024
    Copy the full SHA
    43d39f1 View commit details
  7. Copy the full SHA
    31e4ae6 View commit details
  8. Copy the full SHA
    c9feb0e View commit details
  9. Copy the full SHA
    5b580ae View commit details

Commits on Jul 9, 2024

  1. Update to latest randomprime

    hesto2 committed Jul 9, 2024
    Copy the full SHA
    99a2b3d View commit details
  2. minor rename

    hesto2 committed Jul 9, 2024
    Copy the full SHA
    de6b34e View commit details

Commits on Jul 10, 2024

  1. Copy the full SHA
    2d8ee0e View commit details
  2. Support for UT

    hesto2 committed Jul 10, 2024
    Copy the full SHA
    225e0b2 View commit details
  3. Copy the full SHA
    8e1d594 View commit details
  4. Update README.md (#8)

    * Update README.md
    
    Made some changes to reflect new changes
    
    * Update README.md
    Nystrata authored Jul 10, 2024
    Copy the full SHA
    5bedc1c View commit details
Showing with 19,860 additions and 4,904 deletions.
  1. +8 −5 .github/workflows/build.yaml
  2. +3 −1 .gitignore
  3. +231 −0 BlastShieldRando.py
  4. +190 −64 ClientReceiveItems.py
  5. +503 −0 Config.py
  6. +271 −25 Container.py
  7. +10 −8 DolphinClient.py
  8. +170 −0 DoorRando.py
  9. +8 −0 Enum.py
  10. +176 −0 ItemPool.py
  11. +281 −48 Items.py
  12. +210 −103 Locations.py
  13. +252 −118 Logic.py
  14. +129 −0 LogicCombat.py
  15. +278 −123 Metroid Prime.yaml
  16. +240 −100 MetroidPrimeClient.py
  17. +399 −115 MetroidPrimeInterface.py
  18. +17 −9 NotificationManager.py
  19. +372 −95 PrimeOptions.py
  20. +126 −0 PrimeUtils.py
  21. +63 −23 README.md
  22. +70 −76 Regions.py
  23. +2 −2 Tools.py
  24. +39 −0 WorldMapping.py
  25. +374 −136 __init__.py
  26. +1 −0 build/apworld.ignore
  27. +148 −121 build/build.sh
  28. +0 −267 config.py
  29. +2 −3 data/AreaNames.py
  30. +596 −0 data/BlastShieldRegions.py
  31. +991 −338 data/ChozoRuins.py
  32. +67 −0 data/DoorData.py
  33. +1 −1 data/LevelData.json
  34. +490 −141 data/MagmoorCaverns.py
  35. +820 −205 data/PhazonMines.py
  36. +913 −286 data/PhendranaDrifts.py
  37. +369 −121 data/RoomData.py
  38. +0 −239 data/RoomNames.json
  39. +239 −240 data/RoomNames.py
  40. +509 −127 data/StartRoomData.py
  41. +559 −331 data/TallonOverworld.py
  42. +232 −40 data/Transports.py
  43. +784 −152 data/Tricks.py
  44. +818 −0 data/elevator_access.json
  45. +16 −0 debug/debug_bulk_generate.py
  46. +75 −0 debug/debug_elevators.py
  47. +45 −0 debug/parse_elevator_text.py
  48. +1 −0 docs/en_Metroid Prime.md
  49. +143 −94 docs/setup_en.md
  50. +1 −1 requirements-linux.txt
  51. +3 −3 requirements.txt
  52. +673 −0 test/TestBlastShieldRando.py
  53. +285 −0 test/TestDoorRando.py
  54. +71 −0 test/TestElevatorRandomization.py
  55. +37 −0 test/TestMiscOptions.py
  56. +39 −15 test/TestOutputGeneration.py
  57. +41 −0 test/TestProgressiveBeamUpgrades.py
  58. +72 −0 test/TestShuffleScanVisor.py
  59. +100 −0 test/TestStartingBeamRando.py
  60. +216 −30 test/TestStartingRoom.py
  61. +47 −0 test/TestTricks.py
  62. +173 −0 test/TestUniversalTracker.py
  63. +84 −4 test/__init__.py
  64. +3,081 −0 test/data/all_randomized.json
  65. +1,343 −542 test/data/default_config.json
  66. +1,353 −552 test/data/missile_launcher_main_pb_config.json
13 changes: 8 additions & 5 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ permissions:
contents: write
on:
push:
tags: [ "v*" ]
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
@@ -19,13 +19,16 @@ jobs:
bundle:
name: Bundle APWorld
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12"]
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: ${{ matrix.python-version }}
- name: Compute build tag
id: tag
run: |
@@ -42,10 +45,10 @@ jobs:
echo "tag=${SHA}" >> $GITHUB_OUTPUT
fi
- name: Build bundle
run: TAG="${{ steps.tag.outputs.tag }}" bash build/build.sh
run: TAG="${{ steps.tag.outputs.tag }}" PY_VERSION="${{ matrix.python-version}}" bash build/build.sh
- uses: actions/upload-artifact@v4
with:
name: metroidprime_apworld-${{ steps.tag.outputs.tag }}
name: metroidprime_apworld-${{ steps.tag.outputs.tag }}-${{ matrix.python-version }}
path: build/target/metroidprime_apworld-${{ steps.tag.outputs.tag }}.zip
- uses: softprops/action-gh-release@v2
if: github.ref_type == 'tag'
@@ -54,4 +57,4 @@ jobs:
prerelease: ${{ contains(github.ref_name, '-rc') }}
draft: true
generate_release_notes: true
files: build/target/metroidprime_apworld-${{ steps.tag.outputs.tag }}.zip
files: build/target/metroidprime_apworld-${{ steps.tag.outputs.tag }}-${{ matrix.python-version }}.zip
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -2,4 +2,6 @@
__pycache__
*.apmp1
*.iso
test/test_output
test/test_output
lib
testapmp1folder
231 changes: 231 additions & 0 deletions BlastShieldRando.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
from enum import Enum
import math

from .WorldMapping import AreaMapping, AreaMappingDict, WorldMapping
from .data.DoorData import get_door_data_by_room_names
from .data.BlastShieldRegions import get_valid_blast_shield_regions_by_area
from .data.RoomNames import RoomName
from .PrimeOptions import BlastShieldAvailableTypes, BlastShieldRandomization
from .data.AreaNames import MetroidPrimeArea
from typing import TYPE_CHECKING, Any, Dict, List

if TYPE_CHECKING:
from . import MetroidPrimeWorld


class BlastShieldType(Enum):
Bomb = "Bomb"
Charge_Beam = "Charge Beam"
Flamethrower = "Flamethrower"
Ice_Spreader = "Ice Spreader"
Wavebuster = "Wavebuster"
Power_Bomb = "Power Bomb"
Super_Missile = "Super Missile"
Missile = "Missile"
Disabled = "Disabled" # This is technically a door type, but functionally we want to add it the way that shields are added
No_Blast_Shield = "None"


class BlastShieldMapping(Dict[str, Dict[int, BlastShieldType]]):
pass


class AreaBlastShieldMapping(AreaMapping[BlastShieldMapping]):
@classmethod
def from_dict(cls, data: AreaMappingDict) -> AreaMapping[BlastShieldMapping]:
mapping = super().from_dict(data)
# Need to convert the string values to the actual enum values when loading from options
for door_mapping in mapping.type_mapping.values():
for door_id, shield_type in door_mapping.items():
door_mapping[door_id] = BlastShieldType(shield_type)
return mapping


class WorldBlastShieldMapping(WorldMapping[BlastShieldMapping]):
@classmethod
def from_option_value(cls, data: Dict[str, Any]) -> "WorldBlastShieldMapping":
return WorldBlastShieldMapping(
super().from_option_value_generic(data, AreaBlastShieldMapping)
)

def to_option_value(self) -> Dict[str, AreaMappingDict]:
"""This needs to convert these to raw dictionaries otherwise the AP server interprets the slot data as a class and fails"""
value = super().to_option_value()
for mapping in value.values():
new_type_mapping: Dict[str, Dict[int, str]] = dict()
for key, door_mapping in mapping["type_mapping"].items():
new_door_mapping: Dict[int, str] = dict()
for door_id, shield_type in door_mapping.items():
new_door_mapping[door_id] = shield_type.value
new_type_mapping[key] = new_door_mapping
mapping["type_mapping"] = new_type_mapping
return value


MAX_BEAM_COMBO_DOORS_PER_AREA = 1
ALL_SHIELDS: List[BlastShieldType] = [shield for shield in BlastShieldType]
BEAM_COMBOS: List[BlastShieldType] = [
BlastShieldType.Flamethrower,
BlastShieldType.Ice_Spreader,
BlastShieldType.Wavebuster,
]


def get_world_blast_shield_mapping(
world: "MetroidPrimeWorld",
) -> WorldBlastShieldMapping:
mapping: Dict[str, AreaBlastShieldMapping] = {}
areas_with_locks = []
if world.options.locked_door_count > 0:
# No locks for magmoor since it is too linear and a central hub
areas_with_locks = world.random.sample(
[
area
for area in list(MetroidPrimeArea)
if area != MetroidPrimeArea.Magmoor_Caverns
],
world.options.locked_door_count.value,
)

if (
world.options.blast_shield_randomization.value
!= BlastShieldRandomization.option_none
):
for area in MetroidPrimeArea:
mapping[area.value] = AreaBlastShieldMapping(
area.value,
_generate_blast_shield_mapping_for_area(
world, area, area in areas_with_locks
),
)

# Still generate mapping for areas with locks even if blast shields are disabled
elif areas_with_locks:
for area in areas_with_locks:
mapping[area.value] = AreaBlastShieldMapping(
area.value, _generate_blast_shield_mapping_for_area(world, area, True)
)
return WorldBlastShieldMapping(mapping)


def _generate_blast_shield_mapping_for_area(
world: "MetroidPrimeWorld", area: MetroidPrimeArea, include_locked_door: bool
) -> BlastShieldMapping:
area_mapping: BlastShieldMapping = BlastShieldMapping({})
total_beam_combo_doors = 0

if (
world.options.blast_shield_randomization.value
== BlastShieldRandomization.option_mix_it_up
):
blast_shield_regions = get_valid_blast_shield_regions_by_area(world, area)
num_shields_to_add = math.ceil(
world.options.blast_shield_frequency.value * len(blast_shield_regions) * 0.1
)
world.random.shuffle(blast_shield_regions)
for i in range(num_shields_to_add):
region = blast_shield_regions[i]
source_room = world.random.choice(list(region.doors.keys()))
door_data = get_door_data_by_room_names(
source_room, region.doors[source_room], area, world
)

assert door_data is not None
_, door_id = door_data

shield_type = world.random.choice(
_get_available_blast_shields(
world, total_beam_combo_doors >= MAX_BEAM_COMBO_DOORS_PER_AREA
)
)
if source_room.value not in area_mapping:
area_mapping[source_room.value] = {}
area_mapping[source_room.value][door_id] = shield_type

if shield_type in BEAM_COMBOS:
total_beam_combo_doors += 1

elif (
world.options.blast_shield_randomization.value
== BlastShieldRandomization.option_replace_existing
):
for room_name, room_data in world.game_region_data[area].rooms.items():
for door_id, door_data in room_data.doors.items():
if door_data.blast_shield:
if room_name.value not in area_mapping:
area_mapping[room_name.value] = {}

shield_type = world.random.choice(
_get_available_blast_shields(
world,
total_beam_combo_doors >= MAX_BEAM_COMBO_DOORS_PER_AREA,
)
)
area_mapping[room_name.value][door_id] = shield_type

if shield_type in BEAM_COMBOS:
total_beam_combo_doors += 1

if include_locked_door:
lockable_regions = [
regions
for regions in get_valid_blast_shield_regions_by_area(world, area)
if regions.can_be_locked
]
if lockable_regions:
region = lockable_regions[0]
source_room = world.random.choice(list(region.doors.keys()))
door_data = get_door_data_by_room_names(
source_room, region.doors[source_room], area, world
)
assert door_data is not None
_, door_id = door_data
if source_room.value not in area_mapping:
area_mapping[source_room.value] = {}
area_mapping[source_room.value][door_id] = BlastShieldType.Disabled

return area_mapping


def _get_available_blast_shields(
world: "MetroidPrimeWorld", force_exclude_combo_doors: bool = False
) -> List[BlastShieldType]:
available_shields = [
shield
for shield in ALL_SHIELDS.copy()
if shield not in [BlastShieldType.Disabled, BlastShieldType.No_Blast_Shield]
]
if (
world.options.blast_shield_randomization
== BlastShieldRandomization.option_replace_existing
):
available_shields.remove(BlastShieldType.Missile)

if (
world.options.blast_shield_available_types
== BlastShieldAvailableTypes.option_all
and not force_exclude_combo_doors
):
return available_shields
else:
return [shield for shield in available_shields if shield not in BEAM_COMBOS]


def apply_blast_shield_mapping(world: "MetroidPrimeWorld"):
remove_vanilla_blast_shields(world)
assert world.blast_shield_mapping is not None
mapping = world.blast_shield_mapping
for area, area_mapping in mapping.items():
for room_name, door_mapping in area_mapping.type_mapping.items():
for door_id, shield_type in door_mapping.items():
world.game_region_data[MetroidPrimeArea(area)].rooms[
RoomName(room_name)
].doors[door_id].blast_shield = shield_type


def remove_vanilla_blast_shields(world: "MetroidPrimeWorld"):
for area in MetroidPrimeArea:
for room_data in world.game_region_data[area].rooms.values():
for door_data in room_data.doors.values():
if door_data.blast_shield:
door_data.blast_shield = BlastShieldType.No_Blast_Shield
254 changes: 190 additions & 64 deletions ClientReceiveItems.py

Large diffs are not rendered by default.

503 changes: 503 additions & 0 deletions Config.py

Large diffs are not rendered by default.

296 changes: 271 additions & 25 deletions Container.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,143 @@
import os
import struct
from typing import List
from typing import TYPE_CHECKING, List, Optional
import zipfile
from worlds.Files import APContainer
import py_randomprime
import py_randomprime # type: ignore

from worlds.metroidprime.MetroidPrimeInterface import GAMES, HUD_MESSAGE_DURATION

from .MetroidPrimeInterface import GAMES, HUD_MESSAGE_DURATION

if TYPE_CHECKING:
from ppc_asm.assembler.ppc import GeneralRegister # type: ignore


class MetroidPrimeContainer(APContainer):
game: str = 'Metroid Prime'
game: str = "Metroid Prime" # type: ignore

def __init__(self, config_json: str, outfile_name: str, output_directory: str,
player=None, player_name: str = "", server: str = ""):
def __init__(
self,
config_json: str,
options_json: str,
outfile_name: str,
output_directory: str,
player: Optional[int] = None,
player_name: str = "",
server: str = "",
):
self.config_json = config_json
self.config_path = "config.json"
self.options_path = "options.json"
self.options_json = options_json
container_path = os.path.join(output_directory, outfile_name + ".apmp1")
super().__init__(container_path, player, player_name, server)

def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
opened_zipfile.writestr(self.config_path, self.config_json)
opened_zipfile.writestr(self.options_path, self.options_json)
super().write_contents(opened_zipfile)


def construct_hud_message_patch(game_version: str) -> List[int]:
from ppc_asm.assembler.ppc import addi, bl, li, lwz, r1, r3, r4, r5, r6, r31, stw, cmpwi, bne, mtspr, blr, lmw, r0, LR, stwu, mfspr, or_, lbz, stmw, stb, lis, r7, r9, nop, ori, GeneralRegister
def add(
output_register: "GeneralRegister",
input_register1: "GeneralRegister",
input_register2: "GeneralRegister",
):
"""
output_register = input_register1 + input_register2
"""
from ppc_asm.assembler.ppc import Instruction

return Instruction.compose(
(
(31, 6, False), # Opcode for add
(output_register.number, 5, False),
(input_register1.number, 5, False),
(input_register2.number, 5, False),
(266, 10, False), # Function code for add
(0, 1, False), # Rc bit
)
)


def slw(
output_register: "GeneralRegister",
input_register: "GeneralRegister",
shift_amount_register: "GeneralRegister",
):
"""
output_register = input_register << shift_amount_register
"""
from ppc_asm.assembler.ppc import Instruction

return Instruction.compose(
(
(31, 6, False), # Opcode for slw
(input_register.number, 5, False),
(output_register.number, 5, False),
(shift_amount_register.number, 5, False),
(24, 10, False), # Function code for slw
(0, 1, False), # Rc bit
)
)


def slwi(
output_register: "GeneralRegister", input_register: "GeneralRegister", literal: int
):
"""
output_register = input_register << shift_amount_register
"""
from ppc_asm.assembler.ppc import Instruction

return Instruction.compose(
(
(21, 6, False), # Opcode for slwi
(input_register.number, 5, False),
(output_register.number, 5, False),
(literal, 5, False),
(0, 5, False),
(31 - literal, 5, False),
(0, 1, False), # Rc bit
)
)


def construct_hook_patch(game_version: str, progressive_beams: bool) -> List[int]:
from ppc_asm.assembler.ppc import (
addi,
bl,
li,
lwz,
r1,
r3,
r4,
r5,
r6,
r31,
stw,
cmpwi,
bne,
mtspr,
blr,
lmw,
r0,
LR,
stwu,
mfspr,
or_,
lbz,
stmw,
stb,
lis,
r7,
r9,
nop,
ori,
GeneralRegister,
)
from ppc_asm import assembler

symbols = py_randomprime.symbols_for_version(game_version)

# UpdateHintState is 0x1BC in length, 111 instructions
@@ -38,23 +150,31 @@ def construct_hud_message_patch(game_version: str) -> List[int]:
stwu(r1, -(patch_stack_length - instruction_size), r1),
mfspr(r0, LR),
stw(r0, patch_stack_length, r1),
stmw(GeneralRegister(block_size - num_preserved_registers), patch_stack_length - instruction_size - num_preserved_registers * instruction_size, r1),
stmw(
GeneralRegister(block_size - num_preserved_registers),
patch_stack_length
- instruction_size
- num_preserved_registers * instruction_size,
r1,
),
or_(r31, r3, r3),

# Check if trigger is set
lis(r6, GAMES[game_version]["HUD_TRIGGER_ADDRESS"] >> 16), # Load upper 16 bits of address
ori(r6, r6, GAMES[game_version]["HUD_TRIGGER_ADDRESS"] & 0xFFFF), # Load lower 16 bits of address
lis(
r6, GAMES[game_version]["HUD_TRIGGER_ADDRESS"] >> 16
), # Load upper 16 bits of address
ori(
r6, r6, GAMES[game_version]["HUD_TRIGGER_ADDRESS"] & 0xFFFF
), # Load lower 16 bits of address
lbz(r5, 0, r6),

cmpwi(r5, 1),
bne('early_return'),

bne("early_return_hud"),
# If trigger is set then reset it to 0
li(r5, 0),
stb(r5, 0, r6),

# Prep function arguments
lis(r5, struct.unpack('<I', struct.pack('<f', HUD_MESSAGE_DURATION))[0] >> 16), # Float duration to show message
lis(
r5, struct.unpack("<I", struct.pack("<f", HUD_MESSAGE_DURATION))[0] >> 16
), # Float duration to show message
li(r6, 0x0),
li(r7, 0x1),
li(r9, 0x9),
@@ -65,28 +185,154 @@ def construct_hud_message_patch(game_version: str) -> List[int]:
stb(r7, 0x17, r1),
stw(r9, 0x18, r1),
addi(r3, r1, 0x1C),
lis(r4, GAMES[game_version]["HUD_MESSAGE_ADDRESS"] >> 16), # Load upper 16 bits of message address
ori(r4, r4, GAMES[game_version]["HUD_MESSAGE_ADDRESS"] & 0xFFFF), # Load lower 16 bits of message address
lis(
r4, GAMES[game_version]["HUD_MESSAGE_ADDRESS"] >> 16
), # Load upper 16 bits of message address
ori(
r4, r4, GAMES[game_version]["HUD_MESSAGE_ADDRESS"] & 0xFFFF
), # Load lower 16 bits of message address
bl(symbols["wstring_l__4rstlFPCw"]),
addi(r4, r1, 0x10),

# Call function
bl(symbols["DisplayHudMemo__9CSamusHudFRC7wstringRC12SHudMemoInfo"]),

lmw(GeneralRegister(block_size - num_preserved_registers), patch_stack_length - instruction_size - num_preserved_registers * instruction_size, r1).with_label('early_return'),
nop().with_label("early_return_hud"),
# Progressive Beam Patch
*construct_progressive_beam_patch(game_version, progressive_beams),
# Early return
lmw(
GeneralRegister(block_size - num_preserved_registers),
patch_stack_length
- instruction_size
- num_preserved_registers * instruction_size,
r1,
).with_label("early_return_beam"),
lwz(r0, patch_stack_length, r1),
mtspr(LR, r0),
addi(r1, r1, patch_stack_length - instruction_size),
blr()
blr(),
]

# Fill remaining instructions with nops
while len(instructions) < num_required_instructions:
instructions.append(nop())

if len(instructions) > num_required_instructions:
raise Exception(
f"Patch function is too long: {len(instructions)}/{num_required_instructions}"
)

return list(
assembler.assemble_instructions(
symbols["UpdateHintState__13CStateManagerFf"], instructions,
symbols=symbols
symbols["UpdateHintState__13CStateManagerFf"], instructions, symbols=symbols
)
)


def _load_player_state_to_r6(game_version: str) -> List[int]:
from ppc_asm.assembler.ppc import lis, ori, lwz, r6, r5

cstate_manager_global = GAMES[game_version]["cstate_manager_global"]
return [
# Get the player state address
lis(
r6, cstate_manager_global >> 16
), # Load upper 16 bits of cstate_manager_global
ori(
r6, r6, cstate_manager_global & 0xFFFF
), # Load lower 16 bits of cstate_manager_global
lwz(
r6, 0x8B8, r6
), # Load the player state address from cstate_manager_global + 0x8B8
# : Dereference the player state address pointer to get the actual player state address
lwz(r6, 0, r6), # Load the player state address from the pointer stored in r6
]


def construct_progressive_beam_patch(
game_version: str, progressive_beams: bool
) -> List[int]:
from ppc_asm.assembler.ppc import (
addi,
bl,
b,
li,
lwz,
r1,
r3,
r4,
r5,
r6,
r8,
r10,
r31,
stw,
cmpwi,
bgt,
mtspr,
blr,
lmw,
r0,
LR,
stwu,
mfspr,
or_,
stmw,
stw,
lis,
r7,
r9,
nop,
ori,
GeneralRegister,
Instruction,
)

def add(
output_register: GeneralRegister,
input_register1: GeneralRegister,
input_register2: GeneralRegister,
):
"""
output_register = input_register1 + input_register2
"""
return Instruction.compose(
(
(31, 6, False), # Opcode for add
(output_register.number, 5, False),
(input_register1.number, 5, False),
(input_register2.number, 5, False),
(266, 10, False), # Function code for add
(0, 1, False), # Rc bit
)
)

if not progressive_beams:
return []
cstate_manager_global = GAMES[game_version]["cstate_manager_global"]
charge_beam_offset = 0x7C
instructions: List = [
# Step 0: Get the player state address
*_load_player_state_to_r6(game_version),
# Step 1: Get the current beam from the player state
lwz(r5, 0x8, r6), # Load the current beam value
# Step 2: Read the value at the beam address in CPlayerState::PowerUps[41]
slwi(r5, r5, 3), # Multiply by 8
addi(r7, r6, 0x2C), # Add powerups array offset + 4 (to get item capacity)
add(r7, r7, r5), # Add power beam offset
lwz(r8, 0, r7), # Load the value at the progressive beam address
# Step 3: Check the value and set the appropriate address
cmpwi(r8, 1),
bgt("activate_charge_beam"),
# If value is 0, set the byte at player state address + charge_beam_offset to 0
li(r9, 0),
b("set_charge_beam"),
# If value is 1, set the byte at player state address + charge_beam_offset to 1
li(r9, 1).with_label("activate_charge_beam"),
# Set charge beam state
addi(r10, r6, charge_beam_offset).with_label(
"set_charge_beam"
), # Calculate player state address + charge_beam_offset
stw(r9, 0, r10), # Store 1 at the calculated address
b("early_return_beam"),
]
return instructions
18 changes: 10 additions & 8 deletions DolphinClient.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from logging import Logger
import dolphin_memory_engine
from typing import Any
import dolphin_memory_engine # type: ignore
import subprocess
import Utils

@@ -11,10 +12,10 @@ class DolphinException(Exception):


class DolphinClient:
dolphin: dolphin_memory_engine
dolphin: dolphin_memory_engine # type: ignore
logger: Logger

def __init__(self, logger):
def __init__(self, logger: Logger):
self.dolphin = dolphin_memory_engine
self.logger = logger

@@ -30,7 +31,8 @@ def connect(self):
self.dolphin.hook()
if not self.dolphin.is_hooked():
raise DolphinException(
"Could not connect to Dolphin, verify that you have a game running in the emulator")
"Could not connect to Dolphin, verify that you have a game running in the emulator"
)

def disconnect(self):
if self.dolphin.is_hooked():
@@ -53,7 +55,7 @@ def verify_target_address(self, target_address: int, read_size: int):
f"{target_address:x} -> {target_address + read_size:x} is not a valid for GC memory"
)

def read_pointer(self, pointer, offset, byte_count):
def read_pointer(self, pointer: int, offset: int, byte_count: int) -> Any:
self.__assert_connected()

address = None
@@ -68,13 +70,13 @@ def read_pointer(self, pointer, offset, byte_count):
address += offset
return self.read_address(address, byte_count)

def read_address(self, address, bytes_to_read):
def read_address(self, address: int, bytes_to_read: int) -> Any:
self.__assert_connected()
self.verify_target_address(address, bytes_to_read)
result = self.dolphin.read_bytes(address, bytes_to_read)
return result

def write_pointer(self, pointer, offset, data):
def write_pointer(self, pointer: int, offset: int, data: Any):
self.__assert_connected()
address = None
try:
@@ -88,7 +90,7 @@ def write_pointer(self, pointer, offset, data):
address += offset
return self.write_address(address, data)

def write_address(self, address, data):
def write_address(self, address: int, data: Any):
self.__assert_connected()
result = self.dolphin.write_bytes(address, data)
return result
170 changes: 170 additions & 0 deletions DoorRando.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import copy
from enum import Enum

from .PrimeOptions import DoorColorRandomization

from .WorldMapping import AreaMapping, WorldMapping

from .data.RoomNames import RoomName

from .Items import SuitUpgrade

from .data.AreaNames import MetroidPrimeArea
from typing import TYPE_CHECKING, Any, Callable, Dict, List


if TYPE_CHECKING:
from . import MetroidPrimeWorld


class DoorLockType(Enum):
Blue = "Blue"
Wave = "Wave Beam"
Ice = "Ice Beam"
Plasma = "Plasma Beam"
Missile = "Missile"
Power_Beam = "Power Beam Only"
Bomb = "Bomb"
None_ = "None"


COLOR_LOCK_TYPES = [
# DoorLockType.Blue, # this requires some extra logic
DoorLockType.Wave,
DoorLockType.Ice,
DoorLockType.Plasma,
]

BEAM_TO_LOCK_MAPPING = {
SuitUpgrade.Power_Beam: DoorLockType.Power_Beam,
SuitUpgrade.Wave_Beam: DoorLockType.Wave,
SuitUpgrade.Ice_Beam: DoorLockType.Ice,
SuitUpgrade.Plasma_Beam: DoorLockType.Plasma,
}


class DoorColorMapping(Dict[str, str]):
pass


class AreaDoorColorMapping(AreaMapping[DoorColorMapping]):
pass


class WorldDoorColorMapping(WorldMapping[DoorColorMapping]):
@classmethod
def from_option_value(cls, data: Dict[str, Any]) -> "WorldDoorColorMapping":
return WorldDoorColorMapping(
super().from_option_value_generic(data, AreaDoorColorMapping)
)


def generate_random_door_color_mapping(
world: "MetroidPrimeWorld", area: MetroidPrimeArea
) -> DoorColorMapping:
shuffled_lock_types = get_available_lock_types(world, area)

def is_valid_mapping(mapping: Dict[str, str]) -> bool:
return all(original != new for original, new in mapping.items())

# Can't start w/ Ice beam when fighting Thardus
def is_valid_mapping_for_quarantine_monitor(mapping: Dict[str, str]) -> bool:
return (
is_valid_mapping(mapping)
and mapping[DoorLockType.Wave.value] != DoorLockType.Ice.value
)

validate_func: Callable[[Dict[str, str]], bool] = lambda mapping: is_valid_mapping(
mapping
)

if (
world.starting_room_data
and world.starting_room_data.name == RoomName.Quarantine_Monitor.value
):
validate_func = is_valid_mapping_for_quarantine_monitor

while True:
world.random.shuffle(shuffled_lock_types)
type_mapping = DoorColorMapping(
{
original.value: new.value
for original, new in zip(COLOR_LOCK_TYPES, shuffled_lock_types)
}
)

# Verify that no color matches its original color
if validate_func(type_mapping):
break

return type_mapping


def get_world_door_mapping(world: "MetroidPrimeWorld") -> WorldDoorColorMapping:
door_type_mapping: Dict[str, AreaDoorColorMapping] = {}

assert world.starting_room_data is not None

if world.options.door_color_randomization == DoorColorRandomization.option_global:
global_mapping = generate_random_door_color_mapping(
world, world.starting_room_data.area
)

for area in MetroidPrimeArea:
door_type_mapping[area.value] = AreaDoorColorMapping(
area.value, copy.deepcopy(global_mapping)
)

else:
for area in MetroidPrimeArea:
mapping = generate_random_door_color_mapping(world, area)
door_type_mapping[area.value] = AreaDoorColorMapping(area.value, mapping)

# Add Bomb doors to a random area if they are enabled
if world.options.include_morph_ball_bomb_doors:
bomb_door_area = world.random.choice(
[area for area in MetroidPrimeArea if area != world.starting_room_data.area]
)
replacement_color = world.random.choice(COLOR_LOCK_TYPES)
door_type_mapping[bomb_door_area.value].type_mapping[
replacement_color.value
] = DoorLockType.Bomb.value

return WorldDoorColorMapping(door_type_mapping)


def get_available_lock_types(
world: "MetroidPrimeWorld", area: MetroidPrimeArea
) -> List[DoorLockType]:
locks = COLOR_LOCK_TYPES[:]
# If start beam is randomized, we replace whatever the mapping to starting beam is with Power Beam Only
if (
world.options.include_power_beam_doors
and not world.options.randomize_starting_beam
):
locks.append(DoorLockType.Power_Beam)
return locks


# This needs to take place after the starting beam is initialized
def remap_doors_to_power_beam_if_necessary(world: "MetroidPrimeWorld"):
if world.options.include_power_beam_doors and world.door_color_mapping:
assert (
world.starting_room_data is not None
and world.starting_room_data.selected_loadout is not None
)
starting_beam = world.starting_room_data.selected_loadout.starting_beam

if starting_beam is not SuitUpgrade.Power_Beam:
assert world.starting_room_data
for area, mapping in world.door_color_mapping.items():
if (
area == world.starting_room_data.area.value
and world.starting_room_data.no_power_beam_door_on_starting_level
):
continue
for original, new in mapping.type_mapping.items():
if new == BEAM_TO_LOCK_MAPPING[starting_beam].value:
world.door_color_mapping[area].type_mapping[
original
] = DoorLockType.Power_Beam.value
8 changes: 8 additions & 0 deletions Enum.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from enum import Enum


class StartRoomDifficulty(Enum):
Normal = -1
Safe = 0
Dangerous = 1
Buckle_Up = 2
176 changes: 176 additions & 0 deletions ItemPool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
from typing import TYPE_CHECKING, List
from BaseClasses import ItemClassification
from .PrimeOptions import BlastShieldAvailableTypes, BlastShieldRandomization
from .Items import (
PROGRESSIVE_ITEM_MAPPING,
MetroidPrimeItem,
ProgressiveUpgrade,
SuitUpgrade,
get_item_for_options,
artifact_table,
)
from .Items import MetroidPrimeItem

if TYPE_CHECKING:
from . import MetroidPrimeWorld


def generate_base_start_inventory(world: "MetroidPrimeWorld") -> List[str]:
assert world.starting_room_data.selected_loadout
starting_items = [
get_item_for_options(
world, world.starting_room_data.selected_loadout.starting_beam
).value
]
starting_items.extend(
get_item_for_options(world, item).value
for item in world.starting_room_data.selected_loadout.loadout
)

if not world.options.shuffle_scan_visor:
starting_items.append(SuitUpgrade.Scan_Visor.value)

return starting_items


def generate_item_pool(world: "MetroidPrimeWorld") -> List[MetroidPrimeItem]:
# These are items that are only added if certain options are set
items: List[MetroidPrimeItem] = [
*[world.create_item(artifact) for artifact in artifact_table],
world.create_item(SuitUpgrade.Morph_Ball.value),
world.create_item(SuitUpgrade.Morph_Ball_Bomb.value),
world.create_item(SuitUpgrade.Thermal_Visor.value),
world.create_item(SuitUpgrade.X_Ray_Visor.value),
world.create_item(SuitUpgrade.Scan_Visor.value),
world.create_item(SuitUpgrade.Grapple_Beam.value),
world.create_item(SuitUpgrade.Space_Jump_Boots.value),
world.create_item(SuitUpgrade.Spider_Ball.value),
world.create_item(SuitUpgrade.Boost_Ball.value),
world.create_item(SuitUpgrade.Varia_Suit.value),
world.create_item(SuitUpgrade.Gravity_Suit.value),
world.create_item(SuitUpgrade.Phazon_Suit.value),
]

# Add missiles
progressive_missiles = 8
for _ in range(progressive_missiles):
items.append(
world.create_item(
SuitUpgrade.Missile_Expansion.value, ItemClassification.progression
)
)
items.append(
world.create_item(
get_item_for_options(world, SuitUpgrade.Missile_Launcher).value,
ItemClassification.progression,
)
)

# Add power bombs
max_power_bombs = 5
for _ in range(0, max_power_bombs - 1): # Main PB option will add one more
items.append(
world.create_item(
SuitUpgrade.Power_Bomb_Expansion.value, ItemClassification.useful
)
)
items.append(
world.create_item(
get_item_for_options(world, SuitUpgrade.Main_Power_Bomb).value,
ItemClassification.progression,
)
)

# Add energy tanks
max_tanks = 14
progression_tanks = 8
for i in range(0, max_tanks):
items.append(
world.create_item(
"Energy Tank",
(
ItemClassification.progression
if i < progression_tanks
else ItemClassification.useful
),
)
)

# Add beams and combos
if world.options.progressive_beam_upgrades:
for progressive_item in PROGRESSIVE_ITEM_MAPPING:
for index in range(3):
items.append(
world.create_item(
progressive_item.value,
get_progressive_beam_classification(
world, progressive_item, index
),
)
)
else:
combo_classification = (
ItemClassification.progression
if requires_beam_combos_for_progression(world)
else ItemClassification.useful
)
items.extend(
(
world.create_item(SuitUpgrade.Power_Beam.value),
world.create_item(SuitUpgrade.Wave_Beam.value),
world.create_item(SuitUpgrade.Ice_Beam.value),
world.create_item(SuitUpgrade.Plasma_Beam.value),
world.create_item(SuitUpgrade.Charge_Beam.value),
world.create_item(SuitUpgrade.Super_Missile.value),
world.create_item(SuitUpgrade.Wavebuster.value, combo_classification),
world.create_item(SuitUpgrade.Ice_Spreader.value, combo_classification),
world.create_item(SuitUpgrade.Flamethrower.value, combo_classification),
)
)

assert world.starting_room_data.selected_loadout

items_to_remove = [
*world.prefilled_item_map.values(),
*generate_base_start_inventory(world),
]

for item in items_to_remove:
for i in range(len(items)):
if items[i].name == item:
items.pop(i)
break

# Fill Missiles for rest
for _ in range(len(items), 100 - len(world.prefilled_item_map.values())):
items.append(
world.create_item(
SuitUpgrade.Missile_Expansion.value, ItemClassification.filler
)
)

return items


def requires_beam_combos_for_progression(world: "MetroidPrimeWorld") -> bool:
return (
world.options.blast_shield_available_types.value
== BlastShieldAvailableTypes.option_all
and world.options.blast_shield_randomization.value
!= BlastShieldRandomization.option_none # type: ignore
)


def get_progressive_beam_classification(
world: "MetroidPrimeWorld", progressive_item: ProgressiveUpgrade, index: int
) -> ItemClassification:
# Charge Beam
if index < 2:
return ItemClassification.progression
# Beam Combos
if index == 2 and (
progressive_item == ProgressiveUpgrade.Progressive_Power_Beam
or requires_beam_combos_for_progression(world)
):
return ItemClassification.progression
return ItemClassification.useful
329 changes: 281 additions & 48 deletions Items.py

Large diffs are not rendered by default.

313 changes: 210 additions & 103 deletions Locations.py

Large diffs are not rendered by default.

370 changes: 252 additions & 118 deletions Logic.py

Large diffs are not rendered by default.

129 changes: 129 additions & 0 deletions LogicCombat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
from enum import Enum

from BaseClasses import CollectionState
from .Items import SuitUpgrade, get_item_for_options
from .Logic import (
can_charge_beam,
can_plasma_beam,
can_power_beam,
can_wave_beam,
can_xray,
has_energy_tanks,
)
from .data.RoomNames import RoomName
import typing

if typing.TYPE_CHECKING:
from . import MetroidPrimeWorld


class CombatLogicDifficulty(Enum):
NO_LOGIC = -1
NORMAL = 0
MINIMAL = 1


def _can_combat_generic(
world: "MetroidPrimeWorld",
state: CollectionState,
normal_tanks: int,
minimal_tanks: int,
requires_charge_beam: bool = True,
) -> bool:
difficulty = CombatLogicDifficulty(world.options.combat_logic_difficulty)
if difficulty == CombatLogicDifficulty.NO_LOGIC:
return True
elif difficulty == CombatLogicDifficulty.NORMAL:
return has_energy_tanks(world, state, normal_tanks) and (
can_charge_beam(world, state) or not requires_charge_beam
)
elif difficulty == CombatLogicDifficulty.MINIMAL:
return has_energy_tanks(world, state, minimal_tanks) and (
can_charge_beam(world, state) or not requires_charge_beam
)
return True


def can_combat_mines(world: "MetroidPrimeWorld", state: CollectionState) -> bool:
return _can_combat_generic(world, state, 5, 3)


def can_combat_labs(world: "MetroidPrimeWorld", state: CollectionState) -> bool:
return world.starting_room_name in [
RoomName.East_Tower.value,
RoomName.Save_Station_B.value,
RoomName.Quarantine_Monitor.value,
] or _can_combat_generic(world, state, 1, 0, False)


def can_combat_thardus(world: "MetroidPrimeWorld", state: CollectionState) -> bool:
"""Require charge and plasma or power for thardus on normal"""
if world.starting_room_name in [
RoomName.Quarantine_Monitor.value,
RoomName.Save_Station_B.value,
]:
return (
can_plasma_beam(world, state)
or can_power_beam(world, state)
or can_wave_beam(world, state)
)
difficulty = world.options.combat_logic_difficulty.value
if difficulty == CombatLogicDifficulty.NO_LOGIC.value:
return True
elif difficulty == CombatLogicDifficulty.NORMAL.value:
return has_energy_tanks(world, state, 3) and (
can_charge_beam(world, state)
and (can_plasma_beam(world, state) or can_power_beam(world, state))
)
elif difficulty == CombatLogicDifficulty.MINIMAL.value:
return (
can_plasma_beam(world, state)
or can_power_beam(world, state)
or can_wave_beam(world, state)
)
return True


def can_combat_omega_pirate(world: "MetroidPrimeWorld", state: CollectionState) -> bool:
return _can_combat_generic(world, state, 6, 3) and can_xray(world, state, True)


def can_combat_flaahgra(world: "MetroidPrimeWorld", state: CollectionState) -> bool:
return (
world.starting_room_name == RoomName.Sunchamber_Lobby.value
or _can_combat_generic(world, state, 2, 1, False)
)


def can_combat_ridley(world: "MetroidPrimeWorld", state: CollectionState) -> bool:
return _can_combat_generic(world, state, 8, 8)


def can_combat_prime(world: "MetroidPrimeWorld", state: CollectionState) -> bool:
return _can_combat_generic(world, state, 8, 5)


def can_combat_ghosts(world: "MetroidPrimeWorld", state: CollectionState) -> bool:
difficulty = world.options.combat_logic_difficulty.value
if difficulty == CombatLogicDifficulty.NO_LOGIC.value:
return True
elif difficulty == CombatLogicDifficulty.NORMAL.value:
return (
can_charge_beam(world, state, SuitUpgrade.Power_Beam)
and can_power_beam(world, state)
and can_xray(world, state, True)
)
elif difficulty == CombatLogicDifficulty.MINIMAL.value:
return can_power_beam(world, state)
return True


def can_combat_beam_pirates(
world: "MetroidPrimeWorld", state: CollectionState, beam_type: SuitUpgrade
) -> bool:
if world.options.combat_logic_difficulty.value in [
CombatLogicDifficulty.NO_LOGIC.value,
CombatLogicDifficulty.MINIMAL.value,
]:
return True
return state.has(get_item_for_options(world, beam_type).value, world.player)
401 changes: 278 additions & 123 deletions Metroid Prime.yaml

Large diffs are not rendered by default.

340 changes: 240 additions & 100 deletions MetroidPrimeClient.py

Large diffs are not rendered by default.

514 changes: 399 additions & 115 deletions MetroidPrimeInterface.py

Large diffs are not rendered by default.

26 changes: 17 additions & 9 deletions NotificationManager.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
import time
from typing import Callable, List


class NotificationManager:
notification_queue = []
time_since_last_message: int = 0
last_message_time: int = 0
message_duration: int = None
send_notification_func = None
notification_queue: List[str] = []
time_since_last_message: float = 0
last_message_time: float = 0
message_duration: float
send_notification_func: Callable[[str], bool]

def __init__(self, message_duration, send_notification_func):
self.message_duration = message_duration / 2 # If there are multiple messages, the duration is shorter
def __init__(
self, message_duration: float, send_notification_func: Callable[[str], bool]
):
self.message_duration = (
message_duration / 2
) # If there are multiple messages, the duration is shorter
self.send_notification_func = send_notification_func

def queue_notification(self, message):
def queue_notification(self, message: str):
self.notification_queue.append(message)

def handle_notifications(self):
self.time_since_last_message = time.time() - self.last_message_time
if len(self.notification_queue) > 0 and self.time_since_last_message >= self.message_duration:
if (
len(self.notification_queue) > 0
and self.time_since_last_message >= self.message_duration
):
notification = self.notification_queue[0]
result = self.send_notification_func(notification)
if result:
467 changes: 372 additions & 95 deletions PrimeOptions.py

Large diffs are not rendered by default.

126 changes: 126 additions & 0 deletions PrimeUtils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import os
import pkgutil
import platform
import sys
import shutil
import tempfile
import zipfile
import glob


def setup_lib_path():
"""Takes the local dependencies and moves them out of the apworld zip file to a temporary directory so the DLLs can be loaded."""
base_path = os.path.dirname(__file__)
lib_path = os.path.join(base_path, "lib")

if ".apworld" in __file__:
print("Extracting library files from metroidprime.apworld ")
zip_file_path = __file__
while not zip_file_path.lower().endswith(".apworld"):
zip_file_path = os.path.dirname(zip_file_path)
lib_folder_path = get_lib_folder_path()
version = get_apworld_version()
print("Using metroidprime.apworld version: ", version)
temp_dir_name = "ap_metroidprime_temp_lib"
target_dir_name = f"{temp_dir_name}_{version}"
temp_base_dir = tempfile.gettempdir()
target_dir_path = os.path.join(temp_base_dir, target_dir_name)
create_new_temp_dir = True

# Validate existing directory
if os.path.exists(target_dir_path):
print(
f"Validating existing directory for version {version}: {target_dir_path}"
)
valid = _validate_temp_dir(target_dir_path)
create_new_temp_dir = not valid

# Create a new temp directory if the existing one is invalid or doesn't exist
if create_new_temp_dir:
print(
f"Creating new temp directory for version {version}: {target_dir_path}"
)
_create_temp_dir(
temp_base_dir,
temp_dir_name,
target_dir_path,
zip_file_path,
lib_folder_path,
)

# Add the library path to sys.path
temp_lib_path = os.path.join(target_dir_path, lib_folder_path)
if temp_lib_path not in sys.path:
sys.path.append(temp_lib_path)
print(f"Library folder added to path: {temp_lib_path}")

return temp_lib_path
else:
print("Using local lib folder")
if lib_path not in sys.path:
sys.path.append(lib_path)
print(f"lib folder added to path: {lib_path}")
return lib_path


def _validate_temp_dir(target_dir_path) -> bool:
# Validate the directory by checking if it has the required files
try:
required_files = [
os.path.join("metroidprime", "lib", "py_randomprime", "version.py"),
os.path.join("metroidprime", "lib", "ppc_asm", "version.py"),
os.path.join("metroidprime", "lib", "dolphin_memory_engine", "version.py"),
]
for file in required_files:
file_path = os.path.join(target_dir_path, file)
if not os.path.exists(file_path):
print(f"Required file missing: {file_path}")
return False
return True
except Exception as e:
print(f"Failed to validate temp directory: {e}")
return False


def _create_temp_dir(
temp_base_dir, temp_dir_name, target_dir_path, zip_file_path, lib_folder_path
):
# Remove other version directories
try:
for dir in glob.glob(os.path.join(temp_base_dir, f"{temp_dir_name}_*")):
if dir != target_dir_path:
shutil.rmtree(dir)
print(f"Removed old version directory: {dir}")
except Exception as e:
print(
f"Failed to remove old version directories, make sure you don't have any archipelago clients/generators already running if you want these removed: {e}"
)

# Extract files to the new version directory
os.makedirs(target_dir_path, exist_ok=True)
with zipfile.ZipFile(zip_file_path, "r") as zip_ref:
for member in zip_ref.namelist():
if member.startswith(lib_folder_path):
zip_ref.extract(member, target_dir_path)
print(f"Library files extracted to: {target_dir_path}")


def get_apworld_version():
# Get version from ./version.txt
# detect if on windows since pathing is handled differently from linux
if platform.system() == "Windows":
path = os.path.join(os.path.dirname(__file__), "version.txt")
else:
path = "version.txt"
version = pkgutil.get_data(__name__, path).decode().strip()
return version


def get_lib_folder_path():
# Get version from ./version.txt
# detect if on windows since pathing is handled differently from linux
if platform.system() == "Windows":
lib_folder_path = "metroidprime/lib"
else:
lib_folder_path = os.path.join("metroidprime", "lib")
return lib_folder_path
86 changes: 63 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,45 +1,85 @@
# Metroid Prime Archipelago

An Archipelago implementation of Metroid Prime multiworld randomizer using [randomprime](https://github.com/randovania/randomprime/)

## Setup Guide

For setup instructions go [here](./docs/setup_en.md).
To get started or for troubleshooting, see [the Setup Guide](./docs/setup_en.md).

## Info
### What does randomization do to this game?

In Metroid Prime, all suit upgrade and expansion items are shuffled into the multiworld, giving the game a greater variety in routing to complete the end goal.

### What is the goal of Metroid Prime when randomized?

The end goal of the randomizer game can consist of:

- Collecting the required amount of Artifacts (amount is configurable)
- Defeating Ridley (configurable)
- Defeating Metroid Prime (configurable)

If randomized, the end goal can be scanned in the Temple Security station.

### Which items can be in another player's world?

## What does randomization do to this game?
All suit upgrades and expansion items can be shuffled in other players' worlds, excluding Power Suit and Combat Visor.

In Metroid Prime all of the suit upgrades and powerups are shuffled into the multiworld. This means you will have to think of creative and non standard ways in order to accomplish your goal.
### What does another world's item look like in Metroid Prime?

## What is the goal of Metroid Prime when randomized?
Multiworld items appear as one of the following:

The goal of randomized Metroid Prime depends on your selected victory condition. The current options allow you to specify a required number of artifacts and then whether you want to count beating Ridley, or both Ridley and Metroid Prime as the victory condition.
- Progression Item: Cog
- Useful Item: Metroid Model with a random texture
- Filler Item: Zoomer Model with a random texture

## Which items can be in another player's world?
### What versions of the Metroid Prime are supported?

All suit upgrades except the following can be found in another player's world:
Only the GameCube versions of the game are supported.
The Wii and Switch version of the game are _not_ supported.

- Power Suit
- Power Beam
- Combat Visor
- Scan Visor
### When the player receives an item, what happens?

## What does another world's item look like in Metroid Prime?
The player will immediately have their suit inventory updated and receive a notification in the Client and a HUD message in-game.

Multiworld items appear as a Metroid Trophy in the game.
### Can I teleport to the starting room?

## When the player receives an item, what happens?
To warp to the starting location,

The player will immediately have their suit inventory updated and receive a notification in the Client.
1. Enter a Save Station
2. When prompted to Save, choose No
3. While choosing No, simultaenously hold down the L and R buttons.

- **Currently there is no in-game HUD notification for this, although this is actively being worked on**
### What happens to my own collected items at Game Over or if the game is reset without saving?
As long as the game is connected to the Client and the Client is connected to the server, items you collected before the Game Over or reset will be kept and returned to you when you re-enter the game, even if you did not save.
(The item dot indicators on the map will still show the item location as not collected, even if the Client gives the items back to you.)

## FAQs
### What Metroid Prime mods/tools does this work with?

- What happens if I pickup an item without having the client running?
It is recommended to use a vanilla ISO with the latest release of [Dolphin](https://dolphin-emu.org/download/#).

- In order for Metroid Prime Archipelago to function correctly, the Client should always be running whenever you are playing through your game. Due to the way location checks are handled, the client will not be aware of any item you have picked up when it is not running except the one you most recently picked up.
- Not thoroughly tested; but some users report that these tools and mods work
- [PrimeHack](https://forums.dolphin-emu.org/Thread-fork-primehack-fps-controls-and-more-for-metroid-prime)
- [Widescreen HUD Mod](<https://wiki.dolphin-emu.org/index.php?title=Metroid_Prime_(GC)#16:9_HUD_Mod>) (Revision 0 "0-00" only)
- [MPItemTracker](https://github.com/UltiNaruto/MPItemTracker)
- Not compatible
- Practice Mod (The AP client is unable to connect to the game with this mod present.)

- Does this work with PrimeHack or the Widescreen Hack?
### Aside from item locations being shuffled, how does this differ from the vanilla game?

- It hasn't been tested extensively, but so far it appears yes
Some of the changes include:

- Does this work with version x, y, or z?
- Currently we only support version `0-00`
- Layout Changes
- The game skips the Space Pirate Frigate introduction sequence, automatically placing you into the Starting Room (default: Tallon Overworld - Landing Site)
- Starting Room can optionally be randomized.
- Elevator destinations can optionally be randomized.
- In Main Plaza, Chozo Ruins, the upper ledge door to Vault is no longer locked.
- Traversing "backwards" through the Pirate Labs in Phendrana is now possible:
In Research Lab Hydra, the switch to disable the force field can be scanned from behind the force field.
- Traversing "backwards" through the Crashed Frigate is now possble:
In Main Ventilation Shaft Section B, the door will be powered up and openable when approached from behind.
- Traversing "backwards" through Upper Phazon Mines can be possible (configurable):
In Main Quarry, the barrier is automatically disabled when entering from Mine Security Station.
- In Elite Research, Phazon Mines, the fight with Phazon Elite can now be started without needing to collect the item in Central Dynamo.
- QOL Changes:
- Spring Ball has been implemented! When Morph Ball Bomb is acquired, Spring Ball can be used. To use Spring Ball, tilt the C-Stick Up.
146 changes: 70 additions & 76 deletions Regions.py
Original file line number Diff line number Diff line change
@@ -1,101 +1,95 @@
import typing

from worlds.metroidprime.Logic import can_ice_beam, can_missile, can_plasma_beam, can_power_beam, can_super_missile, can_thermal, can_wave_beam, can_xray, has_energy_tanks, has_required_artifact_count
from worlds.metroidprime.data.ChozoRuins import ChozoRuinsAreaData
from worlds.metroidprime.data.MagmoorCaverns import MagmoorCavernsAreaData
from worlds.metroidprime.data.PhazonMines import PhazonMinesAreaData
from worlds.metroidprime.data.PhendranaDrifts import PhendranaDriftsAreaData
from worlds.metroidprime.data.RoomNames import RoomName
from worlds.metroidprime.data.TallonOverworld import TallonOverworldAreaData
from BaseClasses import Region
from .Logic import (
can_ice_beam,
can_missile,
can_phazon,
can_plasma_beam,
can_power_beam,
can_scan,
can_super_missile,
can_thermal,
can_wave_beam,
can_xray,
has_required_artifact_count,
)
from .LogicCombat import can_combat_prime, can_combat_ridley
from .data.RoomNames import RoomName
from BaseClasses import CollectionState, Region

if typing.TYPE_CHECKING:
from . import MetroidPrimeWorld


def create_regions(world: 'MetroidPrimeWorld', final_boss_selection):
def create_regions(world: "MetroidPrimeWorld", final_boss_selection: int):
# create all regions and populate with locations
menu = Region("Menu", world.player, world.multiworld)
world.multiworld.regions.append(menu)

TallonOverworldAreaData().create_world_region(world)
ChozoRuinsAreaData().create_world_region(world)
MagmoorCavernsAreaData().create_world_region(world)
PhendranaDriftsAreaData().create_world_region(world)
PhazonMinesAreaData().create_world_region(world)
for area_data in world.game_region_data.values():
area_data.create_world_region(world)

impact_crater = Region("Impact Crater", world.player, world.multiworld)
world.multiworld.regions.append(impact_crater)

mission_complete = Region("Mission Complete", world.player, world.multiworld)
world.multiworld.regions.append(mission_complete)

starting_room = world.multiworld.get_region(world.starting_room_data.name, world.player)
assert world.starting_room_data and world.starting_room_data.name
starting_room = world.get_region(world.starting_room_data.name)
menu.connect(starting_room, "Starting Room")

tallon_transport_to_chozo_west = world.multiworld.get_region(RoomName.Transport_to_Chozo_Ruins_West.value, world.player)
tallon_transport_to_chozo_east = world.multiworld.get_region(RoomName.Transport_to_Chozo_Ruins_East.value, world.player)
tallon_transport_to_chozo_south = world.multiworld.get_region(RoomName.Transport_to_Chozo_Ruins_South.value, world.player)
tallon_transport_to_magmoor_east = world.multiworld.get_region(RoomName.Transport_to_Magmoor_Caverns_East.value, world.player)
tallon_transport_to_phazon_east = world.multiworld.get_region(RoomName.Transport_to_Phazon_Mines_East.value, world.player)

chozo_transport_to_tallon_north = world.multiworld.get_region(RoomName.Transport_to_Tallon_Overworld_North.value, world.player)
chozo_transport_to_magmoor_north = world.multiworld.get_region(RoomName.Transport_to_Magmoor_Caverns_North.value, world.player)
chozo_transport_to_tallon_east = world.multiworld.get_region(RoomName.Transport_to_Tallon_Overworld_East.value, world.player)
chozo_transport_to_tallon_south = world.multiworld.get_region("Chozo Ruins: " + RoomName.Transport_to_Tallon_Overworld_South.value, world.player)

magmoor_transport_to_chozo_north = world.multiworld.get_region(RoomName.Transport_to_Chozo_Ruins_North.value, world.player)
magmoor_transport_to_phazon_west = world.multiworld.get_region(RoomName.Transport_to_Phazon_Mines_West.value, world.player)
magmoor_transport_to_phendrana_north = world.multiworld.get_region(RoomName.Transport_to_Phendrana_Drifts_North.value, world.player)
magmoor_transport_to_phendrana_south = world.multiworld.get_region(RoomName.Transport_to_Phendrana_Drifts_South.value, world.player)
magmoor_transport_to_tallon_west = world.multiworld.get_region(RoomName.Transport_to_Tallon_Overworld_West.value, world.player)

phazon_mines_transport_to_magmoor_south = world.multiworld.get_region("Phazon Mines: " + RoomName.Transport_to_Magmoor_Caverns_South.value, world.player)
phazon_mines_transport_to_tallon_south = world.multiworld.get_region("Phazon Mines: " + RoomName.Transport_to_Tallon_Overworld_South.value, world.player)

phendrana_transport_to_magmoor_west = world.multiworld.get_region(RoomName.Transport_to_Magmoor_Caverns_West.value, world.player)
phendrana_transport_to_magmoor_south = world.multiworld.get_region("Phendrana Drifts: " + RoomName.Transport_to_Magmoor_Caverns_South.value, world.player) # There are two transports to magmoor south, other is in phazon mines

tallon_transport_to_chozo_west.connect(chozo_transport_to_tallon_north, "West Chozo Elevator")
tallon_transport_to_chozo_east.connect(chozo_transport_to_tallon_east, "East Chozo Elevator")
tallon_transport_to_chozo_south.connect(chozo_transport_to_tallon_south, "South Chozo Elevator")
tallon_transport_to_magmoor_east.connect(magmoor_transport_to_tallon_west, "East Magmoor Elevator")
tallon_transport_to_phazon_east.connect(phazon_mines_transport_to_tallon_south, "East Mines Elevator")

chozo_transport_to_tallon_north.connect(tallon_transport_to_chozo_west, "North Tallon Elevator")
chozo_transport_to_tallon_east.connect(tallon_transport_to_chozo_east, "East Tallon Elevator")
chozo_transport_to_tallon_south.connect(tallon_transport_to_chozo_south, "South Tallon Elevator")
def can_access_elevator(world: "MetroidPrimeWorld", state: CollectionState) -> bool:
if world.options.pre_scan_elevators:
return True
return can_scan(world, state)

magmoor_transport_to_chozo_north.connect(chozo_transport_to_magmoor_north, "North Chozo Elevator")
magmoor_transport_to_phazon_west.connect(phazon_mines_transport_to_magmoor_south, "West Mines Elevator")
magmoor_transport_to_phendrana_north.connect(phendrana_transport_to_magmoor_west, "North Phendrana Elevator")
magmoor_transport_to_phendrana_south.connect(phendrana_transport_to_magmoor_south, "South Phendrana Elevator")
magmoor_transport_to_tallon_west.connect(tallon_transport_to_magmoor_east, "West Tallon Elevator")
for mappings in world.elevator_mapping.values():
for elevator, target in mappings.items():
source = world.get_region(elevator)
destination = world.get_region(target)
source.connect(
destination, elevator, lambda state: can_access_elevator(world, state)
)

phendrana_transport_to_magmoor_west.connect(magmoor_transport_to_phendrana_north, "West Magmoor Elevator")
phendrana_transport_to_magmoor_south.connect(magmoor_transport_to_phendrana_south, "South Magmoor Elevator")

artifact_temple = world.multiworld.get_region(RoomName.Artifact_Temple.value, world.player)
artifact_temple = world.get_region(
RoomName.Artifact_Temple.value
)

if final_boss_selection == 0 or final_boss_selection == 2:
artifact_temple.connect(impact_crater, "Crater Access", lambda state: (
can_missile(state, world.player) and
has_required_artifact_count(state, world.player) and
has_energy_tanks(state, world.player, 8) and
can_plasma_beam(state, world.player) and can_wave_beam(state, world.player) and can_ice_beam(state, world.player) and can_power_beam(state, world.player) and
can_xray(state, world.player, True) and can_thermal(state, world.player, True)))
elif final_boss_selection == 1:
artifact_temple.connect(mission_complete, "Mission Complete", lambda state:
can_missile(state, world.player) and
has_required_artifact_count(state, world.player) and (can_plasma_beam(state, world.player) or can_super_missile(state, world.player)) and
has_energy_tanks(state, world.player, 8))
elif final_boss_selection == 3:
artifact_temple.connect(mission_complete, "Mission Complete", lambda state: (
can_missile(state, world.player) and
has_required_artifact_count(state, world.player)))

if (final_boss_selection == 0 or
final_boss_selection == 2):
artifact_temple.connect(
impact_crater,
"Crater Access",
lambda state: (
can_missile(world, state)
and has_required_artifact_count(world, state)
and can_combat_prime(world, state)
and can_combat_ridley(world, state)
and can_phazon(world, state)
and can_plasma_beam(world, state)
and can_wave_beam(world, state)
and can_ice_beam(world, state)
and can_power_beam(world, state)
and can_xray(world, state, True)
and can_thermal(world, state, True)
),
)
impact_crater.connect(mission_complete, "Mission Complete")

# from Utils import visualize_regions
# visualize_regions(world.multiworld.get_region("Menu", world.player), "my_world.puml")
elif final_boss_selection == 1:
artifact_temple.connect(
mission_complete,
"Mission Complete",
lambda state: can_missile(world, state)
and has_required_artifact_count(world, state)
and (can_plasma_beam(world, state) or can_super_missile(world, state))
and can_combat_ridley(world, state),
)
elif final_boss_selection == 3:
artifact_temple.connect(
mission_complete,
"Mission Complete",
lambda state: (
can_missile(world, state) and has_required_artifact_count(world, state)
),
)
4 changes: 2 additions & 2 deletions Tools.py
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@
consolidated_dict = {}
data = {}
# Open and read the JSON fil e
with open('./data/RoomLoadouts.json', 'r') as f:
with open("./data/RoomLoadouts.json", "r") as f:
data = json.load(f)

# Iterate over each row in the data
@@ -19,5 +19,5 @@
consolidated_dict[name] += [items]
print(name, items)

with open('./data/ConsolidatedRoomLoadouts.json', 'w') as f:
with open("./data/ConsolidatedRoomLoadouts.json", "w") as f:
json.dump(consolidated_dict, f, indent=4)
39 changes: 39 additions & 0 deletions WorldMapping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from copy import deepcopy
from dataclasses import dataclass
from typing import Any, Dict, Type, TypeVar, Generic, TypedDict

T = TypeVar("T")


class AreaMappingDict(TypedDict):
area: str
type_mapping: Any


@dataclass
class AreaMapping(Generic[T]):
area: str
type_mapping: T

def to_dict(self) -> AreaMappingDict:
return {
"area": self.area,
"type_mapping": dict(self.type_mapping), # type: ignore
}

@classmethod
def from_dict(cls, data: AreaMappingDict) -> "AreaMapping[T]":
return cls(area=data["area"], type_mapping=data["type_mapping"])


class WorldMapping(Dict[str, AreaMapping[T]], Generic[T]):
def to_option_value(self) -> Dict[str, AreaMappingDict]:
return deepcopy({area: mapping.to_dict() for area, mapping in self.items()})

@classmethod
def from_option_value_generic(
cls, data: Dict[str, Any], area_cls: Type[AreaMapping[T]]
) -> "WorldMapping[T]":
return WorldMapping(
{area: area_cls.from_dict(mapping) for area, mapping in data.items()}
)
510 changes: 374 additions & 136 deletions __init__.py

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions build/apworld.ignore
Original file line number Diff line number Diff line change
@@ -6,3 +6,4 @@ README.md
requirements.txt
Metroid Prime.yaml
*.iso
test
269 changes: 148 additions & 121 deletions build/build.sh
Original file line number Diff line number Diff line change
@@ -25,15 +25,15 @@ SUPPORTED_PLATFORMS=("win_amd64" "manylinux_2_28_x86_64")
# Make sure all the required utilities are installed.
##
function pre_flight() {
local bad="0"
for r in ${reqs[@]}; do
if ! command -v $r > /dev/null; then
echo "!=> Unable to locate the '${r}' utility in \$PATH. Make sure it is installed."
bad="1"
fi
done
local bad="0"
for r in ${reqs[@]}; do
if ! command -v $r >/dev/null; then
echo "!=> Unable to locate the '${r}' utility in \$PATH. Make sure it is installed."
bad="1"
fi
done

[ "${bad}" = "1" ] && exit 1 ||:
[ "${bad}" = "1" ] && exit 1 || :
}

##
@@ -42,56 +42,58 @@ function pre_flight() {
# Uses `requirements.ignore` to specify which files not to copy over from within each of the requirements.
##
function get_deps() {
local platform="$1" requirements_file="$2" to="$3"
echo "=> Bundle requirements for ${platform}"

# DME doesn't publish to the same version as randomprime, this installs the most recent supported linux platform and uses a
# requirements.txt that excludes it
if [ "${platform}" = "manylinux_2_28_x86_64" ]; then
echo " -> Installing dolphin-memory-engine override for ${platform}"
pip install dolphin-memory-engine==1.2.0 \
--target ${to}/${platform} \
--platform ${OLD_LINUX_FOR_DME} \
--only-binary=:all:
fi

# Fetch the libraries binary files for the specified platform.
echo " -> Fetch requirements"
pip install \
--target ${to}/${platform} \
--platform ${platform} \
--only-binary=:all: \
--requirement ${requirements_file}

# If platform is manylinux_2_28_x86_64 then also install dolphin memory engine 1.2.0

# This is for the `.dist-info` folder, which contains the metadata of the mod.
# We just copy over the license file into the main library folder
echo " -> Processing metadata"
for folder in ${to}/${platform}/*.dist-info; do
local dir="$(basename ${folder} | cut -d '-' -f 1)"
cp --verbose "${folder}/LICENSE" "${folder}/../${dir}/" ||:
rm --force --recursive ${folder}
done

# Go though each of the downloaded libraries and copy the relevant parts.
echo " -> Transfer requirements to bundle"
for folder in ${to}/${platform}/*; do
echo " - Processing: ${folder}"

# The actual code of the library.
local dir="$(basename ${folder})"
mkdir -p ${to}/${dir}
rsync \
--progress \
--recursive \
--prune-empty-dirs \
--exclude-from="${CWD}/requirements.ignore" \
"${folder}/" "${to}/${dir}"
done

echo " -> Cleaning"
rm --force --recursive ${to}/${platform}
local platform="$1" requirements_file="$2" to="$3"
echo "=> Bundle requirements for ${platform}"

# DME doesn't publish to the same version as randomprime, this installs the most recent supported linux platform and uses a
# requirements.txt that excludes it
if [ "${platform}" = "manylinux_2_28_x86_64" ]; then
echo " -> Installing dolphin-memory-engine override for ${platform}"
pip install dolphin-memory-engine==1.2.0 \
--target ${to}/${platform} \
--platform ${OLD_LINUX_FOR_DME} \
--only-binary=:all: \
--no-user
fi

# Fetch the libraries binary files for the specified platform.
echo " -> Fetch requirements"
pip install \
--target ${to}/${platform} \
--platform ${platform} \
--only-binary=:all: \
--requirement ${requirements_file} \
--no-user

# If platform is manylinux_2_28_x86_64 then also install dolphin memory engine 1.2.0

# This is for the `.dist-info` folder, which contains the metadata of the mod.
# We just copy over the license file into the main library folder
echo " -> Processing metadata"
for folder in ${to}/${platform}/*.dist-info; do
local dir="$(basename ${folder} | cut -d '-' -f 1)"
cp --verbose "${folder}/LICENSE" "${folder}/../${dir}/" || :
rm --force --recursive ${folder}
done

# Go though each of the downloaded libraries and copy the relevant parts.
echo " -> Transfer requirements to bundle"
for folder in ${to}/${platform}/*; do
echo " - Processing: ${folder}"

# The actual code of the library.
local dir="$(basename ${folder})"
mkdir -p ${to}/${dir}
rsync \
--progress \
--recursive \
--prune-empty-dirs \
--exclude-from="${CWD}/requirements.ignore" \
"${folder}/" "${to}/${dir}"
done

echo " -> Cleaning"
rm --force --recursive ${to}/${platform}
}

##
@@ -102,32 +104,38 @@ function get_deps() {
# * $2: destination directory
##
function mk_apworld() {
local root="$1" destdir="$2"
echo "=> Bundling apworld"
echo "From: ${root}"
echo "To: ${destdir}"
mkdir --parents "${destdir}/metroidprime"
rsync --progress \
--recursive \
--prune-empty-dirs \
--exclude-from="${CWD}/apworld.ignore" \
"${root}/" "${destdir}/metroidprime"
pushd "${destdir}"
zip -9r "metroidprime.apworld" "metroidprime"
popd

rm --force --recursive "${destdir}/metroidprime"
local root="$1" destdir="$2"
echo "=> Bundling apworld"
echo "From: ${root}"
echo "To: ${destdir}"
mkdir --parents "${destdir}/metroidprime"
rsync --progress \
--recursive \
--prune-empty-dirs \
--exclude-from="${CWD}/apworld.ignore" \
"${root}/" "${destdir}/metroidprime"

echo "${tag}" >"${destdir}/metroidprime/version.txt"

# If this already exists then ovewrite it
rm -rf "${destdir}/metroidprime/lib"
mv "${destdir}/lib" "${destdir}/metroidprime/lib"
pushd "${destdir}"
zip -9r "metroidprime.apworld" "metroidprime"
popd

rm --force --recursive "${destdir}/metroidprime"
}

##
# Copy static data into the destination directory.
##
function cp_data() {
local root="$1" destdir="$2"
echo "=> Copying over the extra data"
cp --verbose ${root}/LICENSE.md ${destdir}
cp --verbose ${root}/README.md ${destdir}
cp --verbose "${root}/Metroid Prime.yaml" ${destdir}
local root="$1" destdir="$2"
echo "=> Copying over the extra data"
cp --verbose ${root}/LICENSE.md ${destdir}
cp --verbose ${root}/README.md ${destdir}
cp --verbose "${root}/Metroid Prime.yaml" ${destdir}
}

##
@@ -138,54 +146,73 @@ function cp_data() {
# * $2: The path of the output archive.
##
function bundle() {
local from="$1" out="$2"
echo "=> Finalize bundle"
[ -f "${out}" ] && rm ${out} ||:
pushd "${from}"
zip -9r "${out}" "."
popd
local from="$1" out="$2"
echo "=> Finalize bundle"
[ -f "${out}" ] && rm ${out} || :
pushd "${from}"
zip -9r "${out}" "."
popd
}

##
# Main entry point.
##
function main() {
pre_flight

local target_path="${CWD}/target"
local bundle_base="metroidprime_apworld"
mkdir --parents ${target_path}

case "$1" in
# Clean the build environment.
clean)
find "${target_path}" \
-depth \
-type d \
-name "${bundle_base}-*" \
-exec rm --force --recursive --verbose {} \;
;;

# Create the release bundle.
*)
local tag="${TAG:-$(date '+%Y-%m-%d_%H%M')}"
local project="$(realpath ${CWD}/..)"
local bundle="${bundle_base}-${tag}"
local destdir="${target_path}/${bundle}"

for platform in "${SUPPORTED_PLATFORMS[@]}"; do
local requirements_file="${project}/requirements.txt"
if [ "${platform}" = "manylinux_2_28_x86_64" ]; then
requirements_file="${project}/requirements-linux.txt"
fi
get_deps "${platform}" ${requirements_file} "${destdir}/lib"
done

mk_apworld "${project}" "${destdir}/lib/worlds/"
cp_data "${project}" "${destdir}"
bundle "${destdir}" "${target_path}/${bundle}.zip"
echo "! Bundle finalized as ${target_path}/${bundle}.zip"
;;
esac
pre_flight

local target_path="${CWD}/target"
local bundle_base="metroidprime_apworld"
mkdir --parents ${target_path}

case "$1" in
# Clean the build environment.
clean)
find "${target_path}" \
-depth \
-type d \
-name "${bundle_base}-*" \
-exec rm --force --recursive --verbose {} \;
;;

# Create the release bundle.
*)
local tag="${TAG:-$(date '+%Y-%m-%d_%H%M')}"
local py_version="${PY_VERSION}"
local project="$(realpath ${CWD}/..)"
local bundle="${bundle_base}-${tag}-${py_version}"
local destdir="${target_path}/${bundle}"
local local_install=false

# Loop through all the arguments
for arg in "$@"; do
if [ "$arg" == "--local" ]; then
local_install=true
break
fi
done

if [ "$local_install" == true ]; then
# Copy project/lib to destdir
mkdir -p "${destdir}"
cp -r "${project}/lib" "${destdir}"
echo "=> Local install, copying ${project}/lib to ${destdir}"
else
for platform in "${SUPPORTED_PLATFORMS[@]}"; do
local requirements_file="${project}/requirements.txt"
if [ "${platform}" = "manylinux_2_28_x86_64" ]; then
requirements_file="${project}/requirements-linux.txt"
fi
get_deps "${platform}" ${requirements_file} "${destdir}/lib"
# copy deps to project folder as well for local dev
cp -r "${destdir}/lib" "${project}"
done
fi

mk_apworld "${project}" "${destdir}"
cp_data "${project}" "${destdir}"
bundle "${destdir}" "${target_path}/${bundle}.zip"
echo "! Bundle finalized as ${target_path}/${bundle}.zip"
;;
esac
}
main "$@"
267 changes: 0 additions & 267 deletions config.py

This file was deleted.

5 changes: 2 additions & 3 deletions data/AreaNames.py
Original file line number Diff line number Diff line change
@@ -2,9 +2,8 @@


class MetroidPrimeArea(Enum):
Phendrana_Drifts = "Phendrana Drifts"
Tallon_Overworld = "Tallon Overworld"
Chozo_Ruins = "Chozo Ruins"
Magmoor_Caverns = "Magmoor Caverns"
Tallon_Overworld = "Tallon Overworld"
Phendrana_Drifts = "Phendrana Drifts"
Phazon_Mines = "Phazon Mines"
Impact_Crater = "Impact Crater"
596 changes: 596 additions & 0 deletions data/BlastShieldRegions.py

Large diffs are not rendered by default.

1,329 changes: 991 additions & 338 deletions data/ChozoRuins.py

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions data/DoorData.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from dataclasses import dataclass, field
from typing import Callable, List, Optional, Tuple, TYPE_CHECKING
from .Tricks import TrickInfo
from BaseClasses import CollectionState
from .AreaNames import MetroidPrimeArea
from .RoomNames import RoomName
from ..DoorRando import DoorLockType

if TYPE_CHECKING:
from .. import MetroidPrimeWorld
from ..BlastShieldRando import BlastShieldType


@dataclass
class DoorData:
default_destination: Optional[RoomName]
defaultLock: DoorLockType = DoorLockType.Blue
blast_shield: Optional["BlastShieldType"] = None
lock: Optional[DoorLockType] = None
destination_area: Optional[MetroidPrimeArea] = (
None # Used for rooms that have the same name in different areas like Transport Tunnel A
)
rule_func: Optional[Callable[["MetroidPrimeWorld", CollectionState], bool]] = None
tricks: List[TrickInfo] = field(default_factory=list)
exclude_from_rando: bool = (
False # Used primarily for door rando when a door doesn't actually exist
)
sub_region_door_index: Optional[int] = (
None # Used when this door also provides access to another door in the target room
)
sub_region_access_override: Optional[
Callable[["MetroidPrimeWorld", CollectionState], bool]
] = None # Used to override the access check for reaching this door, if necessary when connecting it to a sub region
indirect_condition_rooms: Optional[List[RoomName]] = None

def get_destination_region_name(self) -> str:
assert self.default_destination is not None
if self.destination_area is not None:
return f"{self.destination_area.value}: {self.default_destination.value}"
return self.default_destination.value


def get_door_data_by_room_names(
source_room: RoomName,
target_room: RoomName,
area: MetroidPrimeArea,
world: "MetroidPrimeWorld",
) -> Optional[Tuple[DoorData, int]]:
region_data = world.game_region_data.get(area)

assert region_data

source_room_data = region_data.rooms.get(source_room)
if not source_room_data:
return None

# Retrieve the target room data
target_room_data = region_data.rooms.get(target_room)
if not target_room_data:
return None

# Iterate through the doors in the source room to find a matching door
for door_id, door_data in source_room_data.doors.items():
if door_data.default_destination == target_room:
return door_data, door_id

return None
2 changes: 1 addition & 1 deletion data/LevelData.json
Original file line number Diff line number Diff line change
@@ -363,7 +363,7 @@
"pickups": [
{
"type": "Unknown Item 1",
"name": "Sunchamber - Flaaghra",
"name": "Sunchamber - Flaahgra",
"currIncrease": 21
},
{
631 changes: 490 additions & 141 deletions data/MagmoorCaverns.py

Large diffs are not rendered by default.

1,025 changes: 820 additions & 205 deletions data/PhazonMines.py

Large diffs are not rendered by default.

1,199 changes: 913 additions & 286 deletions data/PhendranaDrifts.py

Large diffs are not rendered by default.

490 changes: 369 additions & 121 deletions data/RoomData.py

Large diffs are not rendered by default.

239 changes: 0 additions & 239 deletions data/RoomNames.json

This file was deleted.

Loading