diff --git a/agents.md b/agents.md index 6d60035b..3336286e 100644 --- a/agents.md +++ b/agents.md @@ -162,12 +162,15 @@ AssertionError ## 7. Assembly Hooks and CPU Register Preservation -**Error Signature**: Monster sprites appear as garbled noise blocks, or regular enemies have incorrect sprites. -**Cause**: Modifying registers (such as `X` or `Y`) or using incorrect data bank instruction opcodes inside custom assembly subroutines. Specifically: +**Error Signature**: Monster sprites appear as garbled noise blocks, or regular enemies have incorrect sprites, or the game crashes when returning from a hook. +**Cause**: Modifying registers (such as `X` or `Y`), changing register sizes (`A8`/`A16` or `I8`/`I16`) inside a custom assembly subroutine, or using incorrect data bank instruction opcodes. Specifically: 1. Destructively overwriting `X` inside a hook when the caller context relies on `X` containing the original graphics index. 2. Using 16-bit absolute address instructions like `LDA absolute,X` (`0xBD`) to access 24-bit dynamic ROM banks (`F0`), which reads from the incorrect data bank and treats the third address byte as the opcode of the next instruction, shifting the entire instruction stream. -3. Looking up active monster IDs in the wrong table (like general actor list WRAM `$2001,X` where slot indices do not map directly to monster offsets), leading to missing or un-rendered sprite overlays in multi-boss battles. +3. Looking up active monster IDs from the wrong table or using the wrong index, leading to missing or un-rendered sprite overlays in multi-boss battles. +4. Changing register sizes (e.g., using `SEP`/`REP` or `A8`/`A16`) inside a subroutine without preserving and restoring the caller's processor status register `P`. This will corrupt the caller's register size state upon returning, or cause initial instructions in the hook to read garbage high bytes if the caller was in `A16` mode. **Resolution**: - Always preserve and restore modified registers using `PHX`/`PLX` or `PHY`/`PLY` at the boundaries of your subroutine. +- **Always preserve and restore the processor status register P** using `PHP` at the entry and `PLP` at all exit paths of your subroutine if register size modes (`A8`/`A16`, `I8`/`I16`) are changed, ensuring the caller's processor state is perfectly preserved. +- When beginning a custom subroutine that changes register sizes, explicitly execute `A8()` or `A16()` right after `PHP` to ensure a consistent, known accumulator size for the initial instructions, avoiding reading 16-bit garbage high bytes if the caller was in a different mode. - Use 24-bit long addressing opcodes (`0xAF` for `LDA long`, `0xBF` for `LDA long,X`) when referencing addresses dynamically allocated to Bank `F0`. -- Always load active monster IDs directly from the active monster ID table WRAM `$812F,Y` (indexed by slot index * 2) after clearing the 16-bit Accumulator (using `TDC` `0x7B`) at the very beginning of the subroutine to avoid index register pollution from caller active CPU registers. +- Always load active monster IDs directly from the active monster ID table WRAM `$2001,X` (where `X` is `slot_index * 2`) after clearing the 16-bit Accumulator (using `TDC` `0x7B`) to avoid index register pollution. WRAM `$812F,X` is used by the graphics loader to hold the overwritten graphics ID (e.g. setting it to 0 to trigger Imp graphics), and does not hold the actual active monster IDs for lookup. diff --git a/args/bosses.py b/args/bosses.py index 1bb0583d..4fa48857 100644 --- a/args/bosses.py +++ b/args/bosses.py @@ -150,6 +150,7 @@ def options(args): ("Boss Experience", args.boss_experience, "boss_experience"), ("No Undead", args.boss_no_undead, "boss_no_undead"), ("Marshal Keep Lobos", args.boss_marshal_keep_lobos, "boss_marshal_keep_lobos"), + ("Who's There?", args.who_there, "who_there"), ("Oops All Boss ID", oops, "oops"), ] diff --git a/args/graphics.py b/args/graphics.py index d1aaa197..dfadc24c 100644 --- a/args/graphics.py +++ b/args/graphics.py @@ -22,6 +22,8 @@ def parse(parser): graphics.add_argument("-ahtc", "--alternate-healing-text-color", action = "store_true", help = "Makes healing text blue, to be able to distinguish from damage.") + graphics.add_argument("-who", "--who-there", action = "store_true", + help = "Who's There? Bosses look like Imps and have the name '??????'") graphics.add_argument("-steve", "--steveify", type = str, nargs='?', const='Steve', default=None, help = "Steveify the seed: rename all characters, items, espers, magic, enemies, etc. to a given name (default: Steve)") @@ -29,6 +31,7 @@ def process(args): import graphics.palettes.palettes as palettes import graphics.portraits.portraits as portraits import graphics.sprites.sprites as sprites + if args.steveify is not None: if isinstance(args.steveify, bool): @@ -120,6 +123,8 @@ def process(args): else: args.sprite_palettes = DEFAULT_CHARACTER_SPRITE_PALETTES + + def flags(args): flags = "" @@ -144,6 +149,8 @@ def flags(args): flags += " -wmhc" if args.alternate_healing_text_color: flags += " -ahtc" + if args.who_there: + flags += " -who" return flags @@ -195,10 +202,10 @@ def _character_customization_log(args): return log -def _other_options_log(args): - from log import format_option - log = ["Other Graphics"] +def name(): + return "Graphics" +def options(args): remove_flashes = "Original" if args.flashes_remove_worst: remove_flashes = "Worst" @@ -213,13 +220,22 @@ def _other_options_log(args): if args.alternate_healing_text_color: healing_text = "Blue" - entries = [ + return [ ("Remove Flashes", remove_flashes, "remove_flashes"), ("Minimap", world_minimap, "world_minimap"), ("Healing Text", healing_text, "healing_text"), ("Steveify", args.steveify if args.steveify else "None", "steveify"), ] +def menu(args): + return (name(), options(args)) + +def _other_options_log(args): + from log import format_option + log = ["Other Graphics"] + + entries = options(args) + for entry in entries: log.append(format_option(*entry)) diff --git a/data/enemies.py b/data/enemies.py index b384cf45..6ff321a8 100644 --- a/data/enemies.py +++ b/data/enemies.py @@ -426,6 +426,9 @@ def mod(self, maps): self.zones.mod() self.scripts.mod() + if self.args.who_there: + self.who_there_mod() + if self.args.scan_all: self.scan_all() @@ -433,6 +436,96 @@ def mod(self, maps): for enemy in self.enemies: enemy.debug_mod() + def who_there_mod(self): + for enemy in self.enemies: + if enemy.id in bosses.enemy_name or enemy.id == 282: # 282 is Undead SrBehemoth + if enemy.id not in range(343, 352): # Exclude Final Battle Tiers (Short Arm -> Sleep) + enemy.name = "??????" + self.who_there_assembly() + + def who_there_assembly(self): + from memory.space import Bank, Write + import instruction.asm as asm + + # 1. Allocate a 1-byte flag in ROM representing if the flag is active (it will be 1) + who_there_flag_space = Write(Bank.F0, [1], "who's there flag") + flag_addr = who_there_flag_space.start_address_snes + + # 2. Construct the 384-byte boss table + boss_table_bytes = [0] * 384 + for enemy_id in bosses.enemy_name: + if 0 <= enemy_id < 384: + boss_table_bytes[enemy_id] = 1 + boss_table_bytes[282] = 1 # Include SrBehemoth (Undead) Phase 2 + for excluded_id in range(343, 352): # Exclude Final Battle Tiers + boss_table_bytes[excluded_id] = 0 + + boss_table_space = Write(Bank.F0, boss_table_bytes, "who's there boss table") + table_addr = boss_table_space.start_address_snes + + # 3. Create the custom check_imp_graphics subroutine in Bank C0 + src = [ + asm.PHP(), + asm.PHX(), + asm.TDC(), + asm.A8(), + asm.LDA(0x81A7, asm.ABS), + asm.TAY(), + asm.LDA(0x62C2, asm.ABS_Y), + asm.BNE("IS_IMP"), + + asm.LDA(0x81A7, asm.ABS), + asm.ASL(), + asm.TAX(), + + asm.A16(), + asm.LDA(0x2001, asm.ABS_X), # Load the active monster ID from WRAM $2001,X + asm.CMP(384, asm.IMM16), + asm.BCS("NOT_IMP_16"), + asm.TAX(), + asm.A8(), + + asm.LDA(flag_addr, asm.LNG), + asm.BEQ("NOT_IMP"), + + asm.LDA(table_addr, asm.LNG_X), + asm.BNE("IS_IMP"), + + "NOT_IMP_16", + asm.A8(), + "NOT_IMP", + asm.LDA(0x00, asm.IMM8), + asm.PLX(), + asm.PLP(), + asm.RTL(), + + "IS_IMP", + asm.LDA(0x81A7, asm.ABS), + asm.ASL(), + asm.TAX(), + asm.A16(), + asm.LDA(0x0000, asm.IMM16), + asm.STA(0x812F, asm.ABS_X), + asm.A8(), + asm.LDA(0x01, asm.IMM8), + asm.PLX(), + asm.PLP(), + asm.RTL(), + ] + subroutine_space = Write(Bank.C0, src, "who's there check imp graphics") + sub_addr = subroutine_space.start_address_snes + + # 4. Patch the original graphics loader at ROM offset 0x01207B (Bank C1) + patch_src = [ + asm.JSL(sub_addr), + asm.BEQ(0x09), # Branch to 0x01208A + asm.NOP(), + asm.NOP(), + asm.NOP(), + asm.NOP(), + ] + Write(0x01207b, patch_src, "who's there imp graphics loader hook") + def get_event_boss(self, original_boss_name): return self.packs.get_event_boss_replacement(original_boss_name) diff --git a/llms.md b/llms.md index 24ab193f..185ef627 100644 --- a/llms.md +++ b/llms.md @@ -192,6 +192,6 @@ All generated test data, output ROMs, log text files, and metadata manifests gen When implementing visual-only overrides (such as "Who's There?" `-who` / `--who-there` mode): - **Name Obfuscation**: Modify name fields in `data/enemies.py` directly. The data layer will serialize and write the modified text bytes to the ROM during serialization. -- **Visual Sprite Loader Hooking**: Patch the graphics loader logic at ROM offset `0x01207B` (Bank `C1`) using `Reserve` to intercept the check for monster status effects (e.g. WRAM Imp Graphics flags `$62C2,Y` indexed by slot ID). A custom assembly routine in Bank `F0` (via `Allocate`) evaluates conditions (e.g., `-who` flag status and looking up the active monster's ID directly from WRAM `$812F,Y` (indexed by slot * 2) after clearing the Accumulator's high byte using `TDC` (`0x7B`) immediately after `PHX` to avoid index register pollution, against a 384-byte `boss_table` of boss IDs using 24-bit absolute opcodes `0xAF`/`0xBF`). The subroutine redirects rendering to the Imp sprite dynamically without applying the actual status, while strictly preserving Index Register X (`PHX`/`PLX`) to ensure standard graphics rendering for all other enemies remains perfectly intact. +- **Visual Sprite Loader Hooking**: Patch the graphics loader logic at ROM offset `0x01207B` (Bank `C1`) using a hook directing to a custom assembly subroutine in Bank `C0`. The subroutine uses `PHP` and `PLP` to preserve/restore the caller's processor status register `P`, preventing register size corruption. It begins with `A8()` to ensure 8-bit accumulator mode, clears the 16-bit accumulator using `TDC`, and loads the slot index `$81A7`. It evaluates the `-who` flag status and looks up the active monster's ID directly from WRAM `$2001,X` (where `X = slot * 2` under `A16` mode) against a 384-byte `boss_table` using 24-bit absolute opcodes `0xAF`/`0xBF`. If a match is found, it overwrites WRAM `$812F,X` with `0` (the Imp ID) to redirect rendering to the Imp sprite dynamically. It uses fully consolidated exit paths (`NOT_IMP` and `NOT_IMP_16` using `PLX`/`PLP`) with all redundant `Y` register reloading completely removed for maximum efficiency. - **Python 3.14 Compatibility**: When selecting random items/espers from a `set` pool (such as `self.available_espers` in `data/espers.py`), never pass the `set` directly to `random.sample()`, as Python 3.9+ deprecates and Python 3.11+ (including Python 3.14) throws `TypeError`. Instead, convert the set to a tuple: `random.sample(tuple(set_pool), 1)[0]`. This satisfies Python's sequence checks while maintaining 100% identical random state consumption and seed choice sequence compatibility with all older Python versions. - **Character Gating & Reward Types**: In `event/event_reward.py`, `single_possible_type(self)` checks if a reward slot allows only a single type. Because Python's `Flag` allows checking membership for compound flag combinations (`RewardType.CHARACTER | RewardType.ESPER in RewardType` evaluates to `True`), checking `possible_types in RewardType` was a major bug that caused compound slots to be treated as single-type slots. This lead to gating deadlocks (`AssertionError` in `choose_reward`) under specific seed/flag configurations. The fix is to explicitly check against exact single-member flags: `return self.possible_types in (RewardType.CHARACTER, RewardType.ESPER, RewardType.ITEM)`.