Files
dockipelago/worlds/fe8/fe8py.py
Jonathan Tinney 7971961166
Some checks failed
Analyze modified files / flake8 (push) Failing after 2m28s
Build / build-win (push) Has been cancelled
Build / build-ubuntu2204 (push) Has been cancelled
ctest / Test C++ ubuntu-latest (push) Has been cancelled
ctest / Test C++ windows-latest (push) Has been cancelled
Analyze modified files / mypy (push) Has been cancelled
Build and Publish Docker Images / Push Docker image to Docker Hub (push) Successful in 5m4s
Native Code Static Analysis / scan-build (push) Failing after 5m2s
type check / pyright (push) Successful in 1m7s
unittests / Test Python 3.11.2 ubuntu-latest (push) Failing after 16m23s
unittests / Test Python 3.12 ubuntu-latest (push) Failing after 28m19s
unittests / Test Python 3.13 ubuntu-latest (push) Failing after 14m49s
unittests / Test hosting with 3.13 on ubuntu-latest (push) Successful in 5m0s
unittests / Test Python 3.13 macos-latest (push) Has been cancelled
unittests / Test Python 3.11 windows-latest (push) Has been cancelled
unittests / Test Python 3.13 windows-latest (push) Has been cancelled
add schedule I, sonic 1/frontiers/heroes, spirit island
2026-04-02 23:46:36 -07:00

1098 lines
39 KiB
Python

# We deliberately do not import [random] directly to ensure that all random
# functions go through the multiworld rng seed.
from random import Random
from collections import defaultdict
from dataclasses import dataclass
from enum import IntEnum
import operator
import itertools
import functools
import logging
from typing import Any, Union, Optional, Callable, Iterable, Tuple
from .util import fetch_json, write_short_le, read_short_le, read_word_le, write_word_le
# XXX: most python lsps can't handle `from .constants import *`, so we have to
# specify these manually...
from .constants import (
ROM_BASE_ADDRESS,
CHAPTER_UNIT_SIZE,
INVENTORY_INDEX,
INVENTORY_SIZE,
COORDS_INDEX,
REDA_COUNT_INDEX,
REDA_PTR_INDEX,
CHARACTER_TABLE_BASE,
CHARACTER_SIZE,
CHARACTER_WRANK_OFFSET,
CHARACTER_STATS_OFFSET,
CHARACTER_GROWTHS_OFFSET,
CHAR_ABILITY_4_OFFSET,
JOB_TABLE_BASE,
JOB_SIZE,
JOB_STATS_OFFSET,
JOB_CAPS_OFFSET,
STATS_COUNT,
EIRIKA,
EIRIKA_LORD,
EIRIKA_LOCK,
EPHRAIM,
EPHRAIM_LORD,
EPHRAIM_LOCK,
EIRIKA_RAPIER_OFFSET,
ROSS_CH2_HP_OFFSET,
MOVEMENT_COST_TABLE_BASE,
MOVEMENT_COST_ENTRY_SIZE,
MOVEMENT_COST_ENTRY_COUNT,
MOVEMENT_COST_SENTINEL,
IMPORTANT_TERRAIN_TYPES,
ITEM_TABLE_BASE,
ITEM_SIZE,
ITEM_ABILITY_1_INDEX,
UNBREAKABLE_FLAG,
LOCKPICK,
CHEST_KEY_5,
HOLY_WEAPON_IDS,
MOUNTED_AID_CANTO_MASK,
MOUNTED_MONSTERS,
JOB_ABILITY_1_INDEX,
CH15_AUTO_STEEL_SWORD,
CH15_AUTO_STEEL_LANCE,
AI1_INDEX,
INTERNAL_RANDO_CLASS_WEIGHTS_OFFS,
INTERNAL_RANDO_CLASS_WEIGHT_ENTRY_SIZE,
INTERNAL_RANDO_CLASS_WEIGHTS_COUNT,
INTERNAL_RANDO_CLASS_WEIGHT_NUM_CLASSES,
INTERNAL_RANDO_WEAPONS_OFFS,
INTERNAL_RANDO_WEAPONS_ENTRY_SIZE,
INTERNAL_RANDO_WEAPONS_MAX_CLASSES,
INTERNAL_RANDO_WEAPON_TABLE_ROWS,
FEMALE_JOBS,
SONG_TABLE_BASE,
SONG_SIZE,
)
DEBUG = False
# CR cam: Maybe these should go into [constants]?
WEAPON_DATA = "data/weapondata.json"
JOB_DATA = "data/jobdata.json"
SONG_DATA = "data/songdata.json"
CHARACTERS = "data/characters.json"
CHAPTER_UNIT_BLOCKS = "data/chapter_unit_blocks.json"
INTERNAL_RANDO_VALID_DISTRIBS = "data/internal_rando_distribs.json"
def encode_unit_coords(x: int, y: int) -> int:
return y << 6 | x
def int_if_possible(x: str) -> Union[int, str]:
try:
return int(x)
except ValueError:
return x
class UnitBlock:
name: str
base: int
count: int
# Currently, the names of blocks in `chapter_unit_blocks.json` are mostly
# automatically generated from chapter event disassembly and are tagged
# with any relevant information about the block.
logic: defaultdict[Union[int, str], dict[str, Any]]
def __init__(
self, name: str, base: int, count: int, logic: dict[str, dict[str, Any]]
):
self.name = name
self.base = base
self.count = count
self.logic = defaultdict(
dict, {int_if_possible(k): v for k, v in logic.items()}
)
class GrowthRandoKind(IntEnum):
NONE = 0
REDISTRIBUTE = 1
DELTA = 2
FULL = 3
class MusicRandoKind(IntEnum):
VANILLA = 0
CONTEXT = 1
CHAOS = 2
class WeaponKind(IntEnum):
SWORD = 0x00
LANCE = 0x01
AXE = 0x02
BOW = 0x03
STAFF = 0x04
ANIMA = 0x05
LIGHT = 0x06
DARK = 0x07
ITEM = 0x09
MONSTER_WEAPON = 0x0B
RING = 0x0C
DRAGONSTONE = 0x11
@classmethod
def get_valid_names(cls) -> list[str]:
return [
"Sword",
"Lance",
"Axe",
"Bow",
"Staff",
"Anima",
"Light",
"Dark",
"Item",
"Monster Weapon",
"Ring",
"Dragonstone",
]
@classmethod
def of_str(cls, s: str) -> "WeaponKind":
match s:
case "Sword":
return WeaponKind.SWORD
case "Lance":
return WeaponKind.LANCE
case "Axe":
return WeaponKind.AXE
case "Bow":
return WeaponKind.BOW
case "Staff":
return WeaponKind.STAFF
case "Anima":
return WeaponKind.ANIMA
case "Light":
return WeaponKind.LIGHT
case "Dark":
return WeaponKind.DARK
case "Item":
return WeaponKind.ITEM
case "Monster Weapon":
return WeaponKind.MONSTER_WEAPON
case "Ring":
return WeaponKind.RING
case "Dragonstone":
return WeaponKind.DRAGONSTONE
raise ValueError
def damaging(self) -> bool:
match self:
case WeaponKind.SWORD:
return True
case WeaponKind.LANCE:
return True
case WeaponKind.AXE:
return True
case WeaponKind.BOW:
return True
case WeaponKind.STAFF:
return False
case WeaponKind.ANIMA:
return True
case WeaponKind.LIGHT:
return True
case WeaponKind.DARK:
return True
case WeaponKind.ITEM:
return False
case WeaponKind.MONSTER_WEAPON:
return True
case WeaponKind.RING:
return False
case WeaponKind.DRAGONSTONE:
return True
raise ValueError
class WeaponRank(IntEnum):
E = 0x1
D = 0x1F
C = 0x47
B = 0x79
A = 0xB5
S = 0xFB
@classmethod
def of_str(cls, s: str) -> "WeaponRank":
match s:
case "E":
return WeaponRank.E
case "D":
return WeaponRank.D
case "C":
return WeaponRank.C
case "B":
return WeaponRank.B
case "A":
return WeaponRank.A
case "S":
return WeaponRank.S
raise ValueError
@dataclass
class WeaponData:
id: int
name: str
rank: WeaponRank
kind: WeaponKind
locks: set[str]
@classmethod
def of_object(cls, obj: dict[str, Any]):
return WeaponData(
id=obj["id"],
name=obj["name"],
rank=WeaponRank.of_str(obj["rank"]),
kind=WeaponKind.of_str(obj["kind"]),
locks=obj.get("locks", set()),
)
@dataclass
class JobData:
id: int
name: str
is_promoted: bool
usable_weapons: set[WeaponKind]
tags: set[str]
@classmethod
def of_object(cls, obj: dict[str, Any]):
return JobData(
id=obj["id"],
name=obj["name"],
is_promoted=obj["is_promoted"],
usable_weapons=set(
WeaponKind.of_str(kind) for kind in obj["usable_weapons"]
),
tags=set(obj["tags"]),
)
def __hash__(self):
return self.id
def __eq__(self, other):
if not isinstance(other, JobData):
return False
return self.id == other.id
class CharacterStore:
names_by_id: dict[int, str]
ids_by_name: dict[str, list[int]]
character_jobs: dict[str, JobData]
character_tags: dict[str, set[str]]
def __init__(self, char_data: dict[str, dict[str, Any]]):
self.names_by_id = {}
self.character_tags = dict()
self.ids_by_name = dict()
for name, data in char_data.items():
for i in data["ids"]:
assert isinstance(i, int)
self.names_by_id[i] = name
# CR cam: figure out how to convince mypy that `data["tags"]` is
# actually a list of strings
self.character_tags[name] = set(data["tags"])
self.ids_by_name[name] = data["ids"]
self.character_jobs = {}
def lookup_ids(self, char_name: str) -> Optional[list[int]]:
if char_name not in self.ids_by_name:
return None
return self.ids_by_name[char_name]
def lookup_name(self, char_id: int) -> Optional[str]:
if char_id not in self.names_by_id:
return None
return self.names_by_id[char_id]
def tags(self, char: Union[int, str]) -> Optional[set[str]]:
if isinstance(char, int):
if char not in self.names_by_id:
return None
name = self.names_by_id[char]
else:
name = char
return self.character_tags[name]
def __setitem__(self, char: Union[int, str], job: JobData) -> None:
if isinstance(char, int):
if char not in self.names_by_id:
return
name = self.names_by_id[char]
else:
name = char
self.character_jobs[name] = job
def __getitem__(self, char: Union[int, str]):
name = char if isinstance(char, str) else self.names_by_id[char]
return self.character_jobs[name]
def __contains__(self, char: Union[int, str]) -> bool:
if isinstance(char, int):
if char not in self.names_by_id:
return False
name = self.names_by_id[char]
else:
name = char
return name in self.character_jobs
# CR cam: Eirika and Ephraim should be able to use their respective weapons if
# they get randomized into the right class.
def weapon_usable(weapon: WeaponData, job: JobData, logic: dict[str, Any]) -> bool:
if weapon.kind not in job.usable_weapons:
return False
if any(lock not in job.tags for lock in weapon.locks):
return False
if "must_fight" in logic and weapon.kind in [
WeaponKind.ITEM,
WeaponKind.STAFF,
WeaponKind.RING,
]:
return False
return True
# CR cam: ensure that all the progression weapons are usable
# CR-soon cam: This class does way too much. We should refactor this so things
# like `apply_5x_buffs` can happen external to this class.
class FE8Randomizer:
unit_blocks: dict[str, list[UnitBlock]]
weapons_by_id: dict[int, WeaponData]
weapons_by_name: dict[str, WeaponData]
weapons_by_rank: dict[WeaponRank, list[WeaponData]]
character_store: CharacterStore
jobs_by_id: dict[int, JobData]
valid_distribs_by_row: dict[int, list[int]]
promoted_jobs: list[JobData]
unpromoted_jobs: list[JobData]
songs: dict[str, dict[int, str]]
random: Random
rom: bytearray
config: dict[str, Any]
def __init__(self, rom: bytearray, random: Random, config: dict[str, Any]):
self.random = random
self.rom = rom
unit_blocks = fetch_json(CHAPTER_UNIT_BLOCKS)
self.config = config
self.unit_blocks = {
name: [UnitBlock(**block) for block in blocks]
for name, blocks in unit_blocks.items()
}
valid_distribs_by_row = fetch_json(INTERNAL_RANDO_VALID_DISTRIBS)
self.valid_distribs_by_row = {
int(k): v for k, v in valid_distribs_by_row.items()
}
item_data = fetch_json(WEAPON_DATA, object_hook=WeaponData.of_object)
job_data = fetch_json(
JOB_DATA,
object_hook=JobData.of_object,
)
# TODO: handle these properly
job_data = [job for job in job_data if job.usable_weapons]
self.character_store = CharacterStore(fetch_json(CHARACTERS))
self.weapons_by_id = {item.id: item for item in item_data}
self.weapons_by_name = {item.name: item for item in item_data}
self.jobs_by_id = {job.id: job for job in job_data}
self.promoted_jobs = [
job for job in job_data if job.is_promoted and "no_rando" not in job.tags
]
self.unpromoted_jobs = [
job
for job in job_data
if not job.is_promoted and "no_rando" not in job.tags
]
self.weapons_by_rank = defaultdict(list)
for weap in self.weapons_by_id.values():
self.weapons_by_rank[weap.rank].append(weap)
# Dark has no E-ranked weapons by default.
self.weapons_by_rank[WeaponRank.E].append(self.weapons_by_name["Flux"])
# cam: Should we allow Lyon to become a monster?
self.weapons_by_rank[WeaponRank.D].append(self.weapons_by_name["Fiery Fang"])
self.weapons_by_rank[WeaponRank.C].append(self.weapons_by_name["Fiery Fang"])
self.weapons_by_rank[WeaponRank.A].append(self.weapons_by_name["Hellfang"])
self.weapons_by_rank[WeaponRank.S].append(self.weapons_by_name["Hellfang"])
self.weapons_by_rank[WeaponRank.A].append(self.weapons_by_name["Fetid Claw"])
self.weapons_by_rank[WeaponRank.S].append(self.weapons_by_name["Fetid Claw"])
# CR-soon cam:
# Darr: Dragon zombies experience the same problem. I've disabled them for now;
# they only have one weapon and E-rank Wretched Air does not sound fun.
#
# Cam: What we need to do is prevent units from randomizing into Dracozombies
# unless they have an A rank weapon. There are a few easy ways to hack that
# in, but I'm going to punt on it for now because that's a bunch of design
# decisions we can make later.
songdata = fetch_json(SONG_DATA)
self.songs = defaultdict(dict)
for song in songdata:
self.songs[song["category"]][int(song["id"], 16)] = song["name"]
def job_valid(self, job: JobData, char: int, logic: dict[str, Any]) -> bool:
# get list of tags that make the job invalid (notags)
# the "no_" prefix adds the tag to the invalid tag list
# "no_flying" makes any job with "flying" tag invalid
notags = set()
# config option for disabling player unit monsters
if "player" in logic and logic["player"] and not self.config["player_monster"]:
notags.add("monster")
for x in logic:
if x.startswith("no_") and logic[x]:
notags.add(x.removeprefix("no_"))
# job is invalid if it has any of the tags in notags
if notags and notags & job.tags:
return False
# CR-soon cam: see above
if job.name in ("Dracozombie", "Revenant", "Entombed"):
return False
if "must_fly" in logic and logic["must_fly"] and "flying" not in job.tags:
# demand that valid job has the "flying" tag
return False
if "must_fight" in logic and logic["must_fight"]:
if "cannot_fight" in job.tags:
return False
if all(not wtype.damaging() for wtype in job.usable_weapons):
return False
return True
def select_new_item(self, job: JobData, item_id: int, logic: dict[str, Any]) -> int:
if item_id == LOCKPICK:
if "Lockpick" in job.tags:
return LOCKPICK
else:
return CHEST_KEY_5
if item_id not in self.weapons_by_id:
return item_id
weapon_attrs = self.weapons_by_id[item_id]
choices = [
weap
for weap in self.weapons_by_rank[weapon_attrs.rank]
if weapon_usable(weap, job, logic)
]
if not choices:
import json
logging.warning("LOGIC ERROR: no viable weapons, defaulting to E rank")
logging.warning(f" job: {job.name}")
logging.warning(f" rank: {weapon_attrs.rank}")
logging.warning(f" logic: {json.dumps(logic, indent=2)}")
choices = [
weap
for weap in self.weapons_by_rank[WeaponRank.E]
if weapon_usable(weap, job, dict())
]
if not choices:
logging.warning(
"LOGIC ERROR (2): still no viable weapons, defaulting to iron sword"
)
choices = [self.weapons_by_name["Iron Sword"]]
return self.random.choice(choices).id
def select_new_inventory(
self, job: JobData, items: bytes, logic: dict[str, Any]
) -> list[int]:
return [self.select_new_item(job, item_id, logic) for item_id in items]
def rewrite_coords(self, offset: int, x: int, y: int):
old_coords = read_short_le(self.rom, offset)
flags = old_coords & 0b1111000000000000
new_coords = encode_unit_coords(x, y)
write_short_le(self.rom, offset, new_coords | flags)
def apply_nudges(self, data_offset: int, nudges: dict[str, list[int]]) -> None:
if "start" in nudges:
x, y = nudges["start"]
start_offs = data_offset + COORDS_INDEX
self.rewrite_coords(start_offs, x, y)
reda_count = self.rom[data_offset + REDA_COUNT_INDEX]
redas_addr = read_word_le(self.rom, data_offset + REDA_PTR_INDEX)
redas_offs = redas_addr - ROM_BASE_ADDRESS
for i in range(reda_count):
if str(i) in nudges:
x, y = nudges[str(i)]
reda_offs = redas_offs + 8 * i
self.rewrite_coords(reda_offs, x, y)
def select_new_job(
self,
job: JobData,
unpromoted_pool: Iterable[JobData],
promoted_pool: Iterable[JobData],
job_valid: Callable[[JobData], bool],
) -> JobData:
new_job_pool = promoted_pool if job.is_promoted else unpromoted_pool
choices = [job for job in new_job_pool if job_valid(job)]
if not choices:
logging.warning("LOGIC ERROR: no valid jobs")
logging.warning(f" original job: {job.name}")
return job
return self.random.choice(choices)
def randomize_chapter_unit(self, data_offset: int, logic: dict[str, Any]) -> None:
# We *could* read the full struct, but we only need a few individual
# bytes, so we may as well extract them ad-hoc.
unit = self.rom[data_offset : data_offset + CHAPTER_UNIT_SIZE]
job_id = unit[1]
# If the unit's class is is not a "standard" class that can be given to
# players, it's probably some NPC or enemy that shouldn't be touched.
if job_id not in self.jobs_by_id:
return
# CR cam: this is dracozombie. prevents randomizing existing dracozombies.
if job_id == 101:
return
job = self.jobs_by_id[job_id]
char = unit[0]
# add character tags to logic
ctags = self.character_store.tags(char)
if not ctags:
ctags = set()
for t in ctags:
if t not in logic:
logic[t] = True
no_store = "no_store" in logic and logic["no_store"]
# config option for disabling player unit randomization
if not self.config["player_rando"] and "player" in logic and logic["player"]:
if char not in self.character_store and not no_store:
self.character_store[char] = job
return
# Affiliation = bits 1,2; unit is player if they're unset
is_player = not bool(unit[3] & 0b0110)
# Autolevel is LSB
autolevel = unit[3] & 1
inventory = unit[INVENTORY_INDEX : INVENTORY_INDEX + INVENTORY_SIZE]
if char in self.character_store:
new_job = self.character_store[char]
else:
new_job = self.select_new_job(
job,
unpromoted_pool=self.unpromoted_jobs,
promoted_pool=self.promoted_jobs,
job_valid=lambda job: self.job_valid(job, char, logic),
)
if not no_store:
self.character_store[char] = new_job
new_inventory = self.select_new_inventory(new_job, inventory, logic)
self.rom[data_offset + 1] = new_job.id
for i, item_id in enumerate(new_inventory):
self.rom[data_offset + INVENTORY_INDEX + i] = item_id
if (
"ai1_mod" in logic
and self.rom[data_offset + AI1_INDEX] == logic["ai1_mod"]["from"]
):
self.rom[data_offset + AI1_INDEX] = logic["ai1_mod"]["to"]
# If an NPC isn't autoleveled, it's probably a boss or important NPC of
# some kind, so we should force its weapon levels in the character
# table.
if not is_player and not autolevel and char in self.character_store:
for item_id in new_inventory:
if item_id not in self.weapons_by_id:
continue
boss_data_offs = CHARACTER_TABLE_BASE + char * CHARACTER_SIZE
weapon = self.weapons_by_id[item_id]
boss_wrank_offs = boss_data_offs + CHARACTER_WRANK_OFFSET + weapon.kind
rank = self.rom[boss_wrank_offs]
self.rom[boss_wrank_offs] = max(rank, weapon.rank)
def randomize_block(self, block: UnitBlock):
for k, v in list(block.logic.items()):
if isinstance(k, int):
continue
assert isinstance(k, str)
if isinstance(v, dict) and "at_least" in v:
affected = self.random.sample(range(block.count), v["at_least"])
else:
affected = list(range(block.count))
for i in affected:
block.logic[i][k] = v
for i in range(block.count):
offset = block.base + i * CHAPTER_UNIT_SIZE
logic = block.logic[i]
if "nudges" in logic:
self.apply_nudges(offset, logic["nudges"])
if "ignore" in logic and logic["ignore"]:
continue
# If this unit is tagged as a monster, its class gets selected by
# the in-game randomizer, meaning we don't have to touch it.
if "monster" in logic and logic["monster"]:
continue
self.randomize_chapter_unit(offset, logic)
# Randomize the classes and possible invtories for the game's internal
# randomizer (used for skirmishes, tower/ruins, and the two random Wights
# with Lyon for some reason).
def randomize_monster_gen(self) -> None:
class JobSet:
promoted: set[JobData]
unpromoted: set[JobData]
def __init__(self):
self.promoted = set()
self.unpromoted = set()
def add(self, job: JobData) -> None:
(self.promoted if job.is_promoted else self.unpromoted).add(job)
def __len__(self):
return len(self.promoted) + len(self.unpromoted)
def pools(self) -> Tuple[set[JobData], set[JobData]]:
return self.unpromoted, self.promoted
def iter(self):
for j in self.unpromoted:
yield j
for j in self.promoted:
yield j
def job_valid_for_internal_rando(job: JobData) -> bool:
# We disable mages because there aren't any entries for them in the
# base weapon tables. Eventually we'll add them back in, but for
# now we can just disable them.
# CR-soon cam: Add these back in
if any(
map(
job.name.startswith,
(
# catches both regular Mages and "Mage Knight"
"Mage",
"Sage",
"Shaman",
"Druid",
"Priest",
"Cleric",
"Monk",
"Bishop",
"Troubadour",
"Valkyrie",
"Summoner",
"Necromancer",
"Pupil",
"Journeyman",
"Recruit",
"Dracozombie",
),
)
):
return False
return True
# CR-soon cam: do this better
weapon_tables = {
(
WeaponKind.of_str(ty) if ty in WeaponKind.get_valid_names() else ty,
level,
): i
for i, (ty, level) in enumerate(INTERNAL_RANDO_WEAPON_TABLE_ROWS)
}
jobset = JobSet()
for i in range(INTERNAL_RANDO_CLASS_WEIGHTS_COUNT):
offs = (
INTERNAL_RANDO_CLASS_WEIGHTS_OFFS
+ i * INTERNAL_RANDO_CLASS_WEIGHT_ENTRY_SIZE
)
for j in range(INTERNAL_RANDO_CLASS_WEIGHT_NUM_CLASSES):
job_id = self.rom[offs + j]
if not job_id or job_id >= 255:
break
job = self.jobs_by_id[job_id]
if job.name == "Dracozombie":
continue
unpromoted_pool, promoted_pool = (
jobset.pools()
# We _could_ repoint this and not need to check, but eh
if len(jobset) >= INTERNAL_RANDO_WEAPONS_MAX_CLASSES
else (self.unpromoted_jobs, self.promoted_jobs)
)
new_job = self.select_new_job(
job,
unpromoted_pool=unpromoted_pool,
promoted_pool=promoted_pool,
job_valid=job_valid_for_internal_rando,
)
self.rom[offs + j] = new_job.id
jobset.add(new_job)
# CR-someday cam: There is a lot of hardcoding going on here. It would
# be nice to move some of the special-casing here to the data files.
for i, job in enumerate(jobset.iter()):
offs = INTERNAL_RANDO_WEAPONS_OFFS + i * INTERNAL_RANDO_WEAPONS_ENTRY_SIZE
row1: Tuple[int, int, int, int, int]
row1weights: Tuple[int, int, int, int, int]
row1distrib: Tuple[int, int, int, int, int]
if "Claw" in job.tags:
pwr, distrib = {
"Revenant": (0, 1),
"Entombed": (1, 3),
"Bael": (2, 26),
"Elder Bael": (3, 27),
}[job.name]
idx = weapon_tables[("Claw", pwr)]
row1 = (idx, 0, 0, 0, 0)
row1weights = (100, 0, 0, 0, 0)
row1distrib = (distrib, 0, 0, 0, 0)
elif "Fang" in job.tags:
pwr = 1 if job.is_promoted else 0
idx = weapon_tables[("Fang", pwr)]
row1 = (idx, 0, 0, 0, 0)
row1weights = (
(25, 75, 0, 0, 0) if job.is_promoted else (75, 25, 0, 0, 0)
)
row1distrib = (13, 0, 0, 0, 0)
elif "MonsterDark" in job.tags:
match job.name:
case "Mogall":
idx = weapon_tables[("MonsterDark", 0)]
distrib_idx = 45
case "Arch Mogall":
idx = weapon_tables[("MonsterDark", 1)]
distrib_idx = 47
case "Gorgon":
idx = weapon_tables[("MonsterDark", 3)]
distrib_idx = 49
case other:
raise ValueError(
f"BUG: unhandled class {other} tagged as `MonsterDark`"
)
row1 = (idx, 0, 0, 0, 0)
row1weights = (100, 0, 0, 0, 0)
row1distrib = (distrib_idx, 0, 0, 0, 0)
elif len(job.usable_weapons) > 1:
lo_pwr, mid_pwr, hi_pwr = (2, 3, 4) if job.is_promoted else (0, 1, 2)
lo_kind1, lo_kind2 = self.random.sample(list(job.usable_weapons), k=2)
lo_idx1 = weapon_tables[(lo_kind1, lo_pwr)]
lo_idx2 = weapon_tables[(lo_kind2, lo_pwr)]
hi_kind1, hi_kind2 = self.random.sample(list(job.usable_weapons), k=2)
hi_idx1 = weapon_tables[(hi_kind1, hi_pwr)]
hi_idx2 = weapon_tables[(hi_kind2, hi_pwr)]
mid_kind = self.random.choice((lo_kind1, lo_kind2, hi_kind1, hi_kind2))
mid_idx = weapon_tables[(mid_kind, mid_pwr)]
row1 = (lo_idx1, hi_idx1, mid_idx, lo_idx2, hi_idx2)
row1weights = (23, 15, 25, 22, 15)
# doens't typecheck
# row1distrib = tuple(
# self.random.choice(self.valid_distribs_by_row[row]) for row in row1
# )
row1distrib = (
self.random.choice(self.valid_distribs_by_row[row1[0]]),
self.random.choice(self.valid_distribs_by_row[row1[1]]),
self.random.choice(self.valid_distribs_by_row[row1[2]]),
self.random.choice(self.valid_distribs_by_row[row1[3]]),
self.random.choice(self.valid_distribs_by_row[row1[4]]),
)
else:
kind = list(job.usable_weapons)[0]
lo_pwr, mid_pwr, hi_pwr = (2, 3, 4) if job.is_promoted else (0, 1, 2)
lo_idx = weapon_tables[(kind, lo_pwr)]
mid_idx = weapon_tables[(kind, mid_pwr)]
hi_idx = weapon_tables[(kind, hi_pwr)]
row1 = (lo_idx, mid_idx, hi_idx, 0, 0)
row1weights = (30, 35, 35, 0, 0)
row1distrib = (
self.random.choice(self.valid_distribs_by_row[row1[0]]),
self.random.choice(self.valid_distribs_by_row[row1[1]]),
self.random.choice(self.valid_distribs_by_row[row1[2]]),
0,
0,
)
self.rom[offs] = job.id
self.rom[offs + 1 : offs + 6] = bytes(row1)
self.rom[offs + 11 : offs + 16] = bytes(row1weights)
self.rom[offs + 21 : offs + 26] = bytes(row1distrib)
def make_monsters_mounted(self) -> None:
for job in MOUNTED_MONSTERS:
entry = JOB_TABLE_BASE + job * JOB_SIZE
self.rom[entry + JOB_ABILITY_1_INDEX] |= MOUNTED_AID_CANTO_MASK
def fix_movement_costs(self) -> None:
"""
Units that spawn over water or mountains can get stuck, causing crashes
or softlocking if their new class cannot walk on those tiles. To resolve
this, the basepatch includes a fix allowing units to walk on certain
terrain types (marked by the sentinel value) if they are otherwise stuck.
"""
for i in range(MOVEMENT_COST_ENTRY_COUNT):
entry = MOVEMENT_COST_TABLE_BASE + i * MOVEMENT_COST_ENTRY_SIZE
for terrain_type in IMPORTANT_TERRAIN_TYPES:
if self.rom[entry + terrain_type] == 255:
self.rom[entry + terrain_type] = MOVEMENT_COST_SENTINEL
def normalize_genders(self) -> None:
for fjob, mjob in FEMALE_JOBS:
fjob_entry = JOB_TABLE_BASE + fjob * JOB_SIZE
mjob_entry = JOB_TABLE_BASE + mjob * JOB_SIZE
fjob_stats_base = fjob_entry + JOB_STATS_OFFSET
mjob_stats_base = mjob_entry + JOB_STATS_OFFSET
fjob_caps_base = fjob_entry + JOB_CAPS_OFFSET
mjob_caps_base = mjob_entry + JOB_CAPS_OFFSET
for i in range(STATS_COUNT + 1):
self.rom[fjob_stats_base + i] = self.rom[mjob_stats_base + i]
self.rom[fjob_caps_base + i] = self.rom[mjob_caps_base + i]
def tweak_lords(self) -> None:
for char, job, lock_mask in [
(EIRIKA, EIRIKA_LORD, EIRIKA_LOCK),
(EPHRAIM, EPHRAIM_LORD, EPHRAIM_LOCK),
]:
# Move some of the lord base stats from the lord classes to the lords
character_entry = CHARACTER_TABLE_BASE + char * CHARACTER_SIZE
stats_base = character_entry + CHARACTER_STATS_OFFSET
lord_entry = JOB_TABLE_BASE + job * JOB_SIZE
job_stats_base = lord_entry + JOB_STATS_OFFSET
for i in range(STATS_COUNT):
roll = self.random.randint(0, 4)
old_base = self.rom[job_stats_base + i]
new_personal_base = min(roll, old_base)
self.rom[stats_base + i] += new_personal_base
self.rom[job_stats_base + i] -= new_personal_base
ability_4_base = character_entry + CHAR_ABILITY_4_OFFSET
self.rom[ability_4_base] |= lock_mask
def fix_cutscenes(self) -> None:
# Eirika's Rapier is given in a cutscene at the start of the chapter,
# rather than being in her inventory
eirika_job = self.character_store["Eirika"]
if any(wkind != WeaponKind.STAFF for wkind in eirika_job.usable_weapons):
new_rapier = self.select_new_item(
eirika_job, self.weapons_by_name["Steel Blade"].id, {}
)
else:
new_rapier = self.random.choice(
[
self.weapons_by_name["Heal"],
self.weapons_by_name["Mend"],
self.weapons_by_name["Recover"],
]
).id
self.rom[EIRIKA_RAPIER_OFFSET] = new_rapier
# While we force Vanessa to fly to give Ross a fighting chance, it's
# very possible that she won't be able to lift him. To make it more
# reasonable to save him, we _also_ set his starting HP.
self.rom[ROSS_CH2_HP_OFFSET] = 15
# Eirika and Ephraim get automatic steels on rejoining in Ch15, which
# need to be adjusted.
ch15_auto_steel_sword = self.select_new_item(
eirika_job, self.weapons_by_name["Steel Sword"].id, {}
)
ephraim_job = self.character_store["Ephraim"]
ch15_auto_steel_lance = self.select_new_item(
ephraim_job, self.weapons_by_name["Steel Lance"].id, {}
)
self.rom[CH15_AUTO_STEEL_SWORD] = ch15_auto_steel_sword
self.rom[CH15_AUTO_STEEL_LANCE] = ch15_auto_steel_lance
# TODO: logic
# - Flying Duessel vs enemy archers in Ephraim 10 may be unbeatable
def apply_base_changes(self) -> None:
for chapter_name, chapter in self.unit_blocks.items():
for block in chapter:
try:
self.randomize_block(block)
except (ValueError, IndexError) as e:
logging.error("crash dump:")
logging.error(f" block_data: {chapter_name}, {block.name}")
logging.error(f" {e}")
raise
self.fix_movement_costs()
self.fix_cutscenes()
self.tweak_lords()
self.make_monsters_mounted()
def apply_5x_buffs(self) -> None:
for char in ["Ephraim", "Forde", "Kyle"]:
ids = self.character_store.lookup_ids(char)
if ids is None:
logging.error(f"Error: apply_5x_buffs: Unable to lookup ids for {char}")
continue
for char_id in ids:
char_base = CHARACTER_TABLE_BASE + CHARACTER_SIZE * char_id
stats_base = char_base + CHARACTER_STATS_OFFSET
for i in range(STATS_COUNT):
self.rom[stats_base + i] += 2
def apply_infinite_holy_weapons(self) -> None:
for weapon_id in HOLY_WEAPON_IDS:
weapon_base = ITEM_TABLE_BASE + weapon_id * ITEM_SIZE
ability_1_base = weapon_base + ITEM_ABILITY_1_INDEX
self.rom[ability_1_base] |= UNBREAKABLE_FLAG
def redistribute_growths(self, total: int) -> list[int]:
cuts = sorted(self.random.sample(range(1, total), STATS_COUNT))
result = []
overflow = 0
for st, end in zip([0]+cuts, cuts+[total]):
growth = end-st
if growth > 255:
result.append(255)
overflow += growth-255
else:
result.append(growth)
while overflow > 0:
available_indices = [i for i, g in enumerate(result) if g < 255]
if not available_indices:
break
i = self.random.choice(available_indices)
result[i] += overflow
if result[i] > 255:
overflow = result[i]-255
result[i] = 255
return result
def randomize_growths(self, kind: GrowthRandoKind, grmin: int, grmax: int) -> None:
if grmin > grmax:
grmin, grmax = grmax, grmin
player_ids: Iterable[int] = itertools.chain.from_iterable(
ids
for ids in (
self.character_store.lookup_ids(char)
for char in self.character_store.character_tags
if "player" in self.character_store.character_tags[char]
)
if ids is not None
)
def roll_delta() -> int:
delta = self.random.randint(grmin, grmax)
direction = self.random.choice([-1, 1])
return delta * direction
for char_id in player_ids:
char_base = CHARACTER_TABLE_BASE + CHARACTER_SIZE * char_id
growths_base = char_base + CHARACTER_GROWTHS_OFFSET
growths = list(self.rom[growths_base : growths_base + STATS_COUNT + 1])
new_growths: list[int]
match kind:
case GrowthRandoKind.NONE:
return
case GrowthRandoKind.REDISTRIBUTE:
total = sum(growths) + roll_delta()
new_growths = self.redistribute_growths(total)
case GrowthRandoKind.DELTA:
new_growths = [max(growth + roll_delta(), 0) for growth in growths]
case GrowthRandoKind.FULL:
new_growths = [self.random.randint(grmin, grmax) for _ in growths]
for i in range(STATS_COUNT + 1):
self.rom[growths_base + i] = max(min(255, new_growths[i]), 0)
def generate_swaps(
self, songs: dict[int, str]
) -> list[Tuple[Tuple[int, str], Tuple[int, str]]]:
ids = list(songs.items())
return list(zip(ids, self.random.sample(ids, k=len(ids))))
def randomize_music(self, kind: MusicRandoKind) -> None:
swaps: list[Tuple[Tuple[int, str], Tuple[int, str]]]
match kind:
case MusicRandoKind.VANILLA:
return
case MusicRandoKind.CONTEXT:
swaps = list(
itertools.chain.from_iterable(
self.generate_swaps(songs)
for (_ctx, songs) in self.songs.items()
)
)
case MusicRandoKind.CHAOS:
swaps = self.generate_swaps(
functools.reduce(operator.or_, self.songs.values())
)
logging.debug("Music rando song swaps:")
ptrs: list[Tuple[int, int]] = []
for (baseid, basename), (newid, newname) in swaps:
logging.debug(f" {basename} ({hex(baseid)}) -> {newname} ({hex(newid)})")
ptrs.append(
(baseid, read_word_le(self.rom, SONG_TABLE_BASE + newid * SONG_SIZE))
)
for id, ptr in ptrs:
write_word_le(self.rom, SONG_TABLE_BASE + id * SONG_SIZE, ptr)