diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..79d0f04e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# normalize source line endings (checkout and commit as LF) +*.py text eol=lf +*.md text eol=lf + +# graphics data is binary; never diff or normalize it +*.pal binary +*.bin binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..0b8f7080 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + pull_request: + +jobs: + rom-guard: + name: ROM safety guard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Fail if ROM or oversized files are tracked + run: | + set -e + rom_files=$(git ls-files | grep -iE '\.(smc|sfc)$' || true) + if [ -n "$rom_files" ]; then + echo "::error::ROM files must never be committed (legal requirement):" + echo "$rom_files" + exit 1 + fi + # largest legitimate source file is ~56KB; anything over 512KB is suspect + big_files=$(git ls-files -z | xargs -0 du -b 2>/dev/null | awk '$1 > 524288 {print $2 " (" $1 " bytes)"}') + if [ -n "$big_files" ]; then + echo "::error::Unexpectedly large files tracked (possible ROM or binary data):" + echo "$big_files" + exit 1 + fi + + test: + name: Tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Syntax check all modules + run: python -m compileall -q . + - name: Run unit tests + run: python -m unittest discover -s tests -v + - name: CLI smoke test + run: python wc.py -h diff --git a/.gitignore b/.gitignore index eaa59db1..191b6831 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,14 @@ __pycache__/ $py.class *-objective.json *-flag.json -wip/ \ No newline at end of file +wip/ + +# ROM files must never be committed (legal requirement) +*.smc +*.sfc +*.SMC +*.SFC + +# generated test artifacts (seed logs, manifests) - see agents.md "Test Data Isolation" +tests/*.txt +tests/*.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..0cc432de --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,100 @@ +# Contributing to Worlds Collide + +Worlds Collide is community-maintained. This guide covers what you need to +know to make changes safely. For deeper architectural reference (memory +model, assembly hooks, troubleshooting), see [llms.md](llms.md) and +[agents.md](agents.md). + +## Requirements + +- Python 3.9+ (CI tests 3.9 through 3.13), standard library only. + Exception: the optional PNG conversion tools in `graphics/tools/` need + Pillow. +- An unheadered FFIII US v1.0 ROM (3,145,728 bytes) for generating seeds. + **Never commit a ROM** — `.gitignore` and CI both guard against it, for + legal reasons. + +## Running and testing + +```sh +python3 wc.py -i ff3.smc -o tests/test_output.smc # roll a seed +python3 wc.py -h # exercise all flag parsing +python3 -m unittest discover -s tests # unit tests (no ROM needed) +``` + +Generated artifacts (`.smc`, logs) belong in `tests/`, which is gitignored +for everything except the unit test sources. CI runs the unit tests, a +syntax check, and `wc.py -h` on every push/PR. + +## The two cardinal rules + +1. **Seed reproducibility.** The same seed + flags must produce the same ROM, + forever. The flag string seeds Python's global `random`, so the *order and + number of RNG calls* is effectively part of the seed format. Reordering + loops, filtering before shuffling, adding an early `random.*` call — all + of these silently change every seed. When changing code near `random` + calls, verify before/after builds of the same seed are byte-identical + (`cmp old.smc new.smc`). Behavior-changing fixes are sometimes warranted, + but must be deliberate and called out in the PR. + +2. **Flags are forever.** Old flagsets must keep working with new versions, + so flags are never removed or renamed. New behavior gets a new flag. + +## How a seed is built + +`wc.py` runs the phases in order (see `wc.py:main`): + +1. `import args` — parses the command line (every module in + `args/arguments.py`'s group list), builds the canonical flag string, and + seeds the RNG. This happens at *import time*; see `args/__init__.py`. +2. `import log` — creates the spoiler log (also at import time). +3. `Memory()` — loads and validates the input ROM into `Space.rom`. +4. `Data()` — reads game data structures (characters, espers, items, …) + from the ROM, applies flag-driven randomization (`mod()` methods). +5. `Events()` — discovers `event/*.py` by naming convention, distributes + character/esper/item rewards, rewrites event scripts. +6. `Menus()`, `Battle()`, `Settings()`, `BugFixes()` — menu rewrites, + battle assembly patches, bug fixes. +7. `data.write()` / `memory.write()` — serialize everything back and write + the output ROM. + +ROM writes go through the memory model in `memory/space.py`: `Reserve()` +fixed vanilla ranges, or `Allocate()`/`Write()` dynamic space freed by +`Free()`. Overflowing a space raises `RomSpaceError` — see agents.md +("Memory Overflow / Bank Exhaustion") for resolutions. + +## Adding things + +**A flag:** each module in `args/` implements the same interface, called by +`args/arguments.py` and the menu/log systems: `parse(parser)` (add argparse +arguments), `process(args)` (validate/derive values), `flags(args)` (rebuild +the canonical flag string), `options(args)`, `menu(args)`, `log(args)`, and +`name()`. Copy an existing small module (e.g. `args/sketch_control.py`) as a +template, and register new modules in `Arguments.groups`. The flag's +behavior is then implemented in `data/`, `event/`, etc. by checking +`self.args.`. + +**An event:** create `event/.py` containing a class whose name is +the module name with underscores removed (e.g. `mt_kolts.py` → `MtKolts`), +subclassing `Event`. A mismatched class name is *silently skipped*. If you +add or remove character/esper reward slots, update +`CHARACTER_ESPER_ONLY_REWARDS` (see agents.md). + +**An objective condition/result:** add the constant in +`constants/objectives/`, then the implementation under +`objectives/conditions/` or `objectives/results/`, following an existing +pair like `quest.py`/`quests.py`. + +**Tests:** new ROM-independent logic (pure Python helpers, encodings, +allocation) should come with unit tests in `tests/test_*.py`. + +## Style + +- Match the surrounding code. The codebase uses 4-space indents, + `snake_case`, and `key = value` spacing in calls. +- Name magic ROM/WRAM addresses, or comment what they are — future readers + cannot grep for `0x3b18` and know it's the enemy level table. +- Prefer raising descriptive exceptions over `assert` for anything that can + fail at seed-generation time (asserts vanish under `python -O`). +- If your change affects module structure, core APIs, or the patterns + documented in `llms.md`/`agents.md`, update those files in the same PR. diff --git a/README.md b/README.md index 24a8aa70..0074a5a8 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Complete objectives while searching the worlds for characters, espers, and items ## Usage +Requires Python 3.9+ and an unheadered FFIII US v1.0 ROM. + ```sh $ python3 wc.py -i ffiii.smc ``` @@ -19,3 +21,9 @@ $ python3 wc.py -i ffiii.smc ```sh $ python3 wc.py -h ``` + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for how the randomizer is put +together, how to add flags/events, and the rules that keep old seeds +reproducible. Never commit ROM files. diff --git a/agents.md b/agents.md index 76e4e727..ea936dd2 100644 --- a/agents.md +++ b/agents.md @@ -12,6 +12,8 @@ This document is written for autonomous AI coding agents (such as Antigravity, S ### 1.1 Python Dependencies The randomizer runs entirely on Python 3 with the standard library. No external pip installations are required. +Exception: the optional developer tools `graphics/tools/png_portrait.py` and `graphics/tools/png_sprite.py` (PNG-to-sprite conversion) require Pillow (`pip install Pillow`). The randomizer itself never imports it. + ### 1.2 Target ROM File To verify ROM modifications and successfully run a seed generation test, a valid Super Nintendo FF6 ROM file is required: - **Filename**: ff3.smc (located in the workspace root). @@ -24,9 +26,16 @@ To verify ROM modifications and successfully run a seed generation test, a valid ### 1.3 Test Data Isolation > [!IMPORTANT] -> **TEST DATA DIRECTORY RULE**: All test output data (including modified test ROMs, debug log text files, or API metadata manifests generated during your test runs) **MUST** be isolated and placed inside a `tests` directory in the workspace root (e.g., `tests/test_output.smc`, `tests/test_output.txt`). +> **TEST DATA DIRECTORY RULE**: All test output data (including modified test ROMs, debug log text files, or API metadata manifests generated during your test runs) **MUST** be isolated and placed inside the `tests` directory in the workspace root (e.g., `tests/test_output.smc`, `tests/test_output.txt`). > - Do not write test output files directly to the root directory. -> - Ensure the `tests` directory is created before running commands (e.g. `mkdir -Force tests`). +> - The `tests` directory also contains the committed unit test suite (`tests/test_*.py`); generated artifacts there are excluded from git via `.gitignore` (`*.smc`, `*.sfc`, `tests/*.txt`, `tests/*.json`). + +### 1.4 Unit Tests +The `tests/` directory contains a unit test suite for the ROM-independent logic (memory allocation, label/branch encoding, compression, seeding, CLI parsing). It requires no ROM file: +```powershell +python3 -m unittest discover -s tests -v +``` +Run this after any change to `memory/`, `utils/`, `seed.py`, or `valid_rom_file.py`, and add tests for new pure-Python logic. The same suite runs in CI (`.github/workflows/ci.yml`) on every push and pull request, along with `python -m compileall`, `wc.py -h`, and a guard that fails if any ROM-like or oversized file is ever committed. --- @@ -64,8 +73,9 @@ During development, agents commonly trigger three specific errors. Here is how t ### 3.1 Memory Overflow / Bank Exhaustion **Error Signature**: ```text -MemoryError: Not enough room in space "custom event toggle": Next (0xc0f124) > End (0xc0f100). Diff: 36 +memory.errors.RomSpaceError: Not enough room in space "custom event toggle": Next (0xc0f124) > End (0xc0f100). Diff: 36 ``` +(`RomSpaceError` subclasses `MemoryError`, so older code or docs referring to `MemoryError` still apply. The overflow is detected *before* any bytes are written, so the ROM buffer is never corrupted by an overflowing write.) **Cause**: You have written more bytes of assembly instructions or static data than the target `Reserve` range fits, or have exceeded the size of a dynamically requested `Allocate` block in that bank. **Resolution**: 1. Check the size parameters in your dynamic allocation: `Allocate(Bank.C0, size, "description")`. Increase `size` to support your assembly array length. diff --git a/args/__init__.py b/args/__init__.py index 3d624d25..bd8cc505 100644 --- a/args/__init__.py +++ b/args/__init__.py @@ -1,3 +1,19 @@ +"""Command line arguments, parsed and processed at import time. + +The first `import args` runs the full pipeline: parse sys.argv (every flag +module listed in Arguments.groups), process/validate values, build the +canonical flag string, seed the global random module (see seed.py), and +compute the sprite hash. The resulting attributes are then injected into +this module's namespace so flag values read as plain module attributes: + + import args + if args.open_world: + ... + +Because parsing happens at import, importing this module (or any module +that imports it, e.g. `log`) requires valid command line arguments — +at minimum `-i INPUT_FILE`. +""" from args.arguments import Arguments arguments = Arguments() diff --git a/args/challenges.py b/args/challenges.py index 046e7c71..34e12f41 100644 --- a/args/challenges.py +++ b/args/challenges.py @@ -115,14 +115,6 @@ def options(args): ("Remove Learnable Spells", args.remove_learnable_spell_ids, "remove_learnable_spell_ids"), ("No Saves", args.no_saves, "no_saves"), ] - - return opts -def _format_spells_log_entries(spell_ids): - from constants.spells import id_spell - spell_entries = [] - for i, spell_id in enumerate(spell_ids): - spell_entries.append(("", id_spell[spell_id], f"rls_{i}")) - return spell_entries def _format_spells_log_entries(spell_ids): from constants.spells import id_spell diff --git a/args/espers.py b/args/espers.py index eb10d0d7..a93c81b0 100644 --- a/args/espers.py +++ b/args/espers.py @@ -204,8 +204,8 @@ def menu(args): if key == "Equipable": value = value.replace("Random", "") entries[index] = (key, value, unique_name) - except: - pass + except AttributeError: + pass # value is not a string (e.g. a submenu), leave entry unchanged return (name(), entries) def log(args): diff --git a/args/graphics.py b/args/graphics.py index 7f3f7b13..cec743c0 100644 --- a/args/graphics.py +++ b/args/graphics.py @@ -198,6 +198,9 @@ def _other_options_log(args): return log +def name(): + return "Graphics" + def log(args): lcolumn = [""] lcolumn.extend(_sprite_palettes_log(args)) @@ -212,4 +215,4 @@ def log(args): rcolumn.extend(_character_customization_log(args)) from log import section - section("Graphics", lcolumn, rcolumn) + section(name(), lcolumn, rcolumn) diff --git a/args/items.py b/args/items.py index fb0bc0c1..6d27d609 100644 --- a/args/items.py +++ b/args/items.py @@ -247,8 +247,8 @@ def menu(args): value = value.replace("Original + Random", "Original + ") value = value.replace("Shuffle + Random", "Shuffle + ") entries[index] = (key, value, unique_name) - except: - pass + except AttributeError: + pass # value is not a string (e.g. a submenu), leave entry unchanged return (name(), entries) @@ -258,9 +258,6 @@ def log(args): log = [name()] entries = options(args) - '''for entry in entries: - log.append(format_option(*entry)) - ''' for entry in entries: key, value, unique_name = entry if key == "Item Rewards": diff --git a/args/lores.py b/args/lores.py index 0b3ef227..98d93672 100644 --- a/args/lores.py +++ b/args/lores.py @@ -86,8 +86,8 @@ def menu(args): value = value.replace("Random Value ", "") value = value.replace("Random Percent ", "") entries[index] = (key, value, unique_name) - except: - pass + except AttributeError: + pass # value is not a string (e.g. a submenu), leave entry unchanged return (name(), entries) def log(args): diff --git a/args/misc_magic.py b/args/misc_magic.py index c886c558..72756382 100644 --- a/args/misc_magic.py +++ b/args/misc_magic.py @@ -1,68 +1,68 @@ -def name(): - return "Misc. Magic" - -def parse(parser): - magic = parser.add_argument_group("Misc. Magic") - - magic_mp = magic.add_mutually_exclusive_group() - magic_mp.add_argument("-mmps", "--magic-mp-shuffle", action = "store_true", - help = "Magic spells' MP costs shuffled") - magic_mp.add_argument("-mmprv", "--magic-mp-random-value", default = None, type = int, - nargs = 2, metavar = ("MIN", "MAX"), choices = range(255), - help = "Magic spells' MP costs randomized") - magic_mp.add_argument("-mmprp", "--magic-mp-random-percent", default = None, type = int, - nargs = 2, metavar = ("MIN", "MAX"), choices = range(201), - help = "Each Magic spell's MP cost set to random percent of original within given range") - -def process(args): - args._process_min_max("magic_mp_random_value") - args._process_min_max("magic_mp_random_percent") - -def flags(args): - flags = "" - - if args.magic_mp_shuffle: - flags += " -mmps" - elif args.magic_mp_random_value: - flags += f" -mmprv {args.magic_mp_random_value_min} {args.magic_mp_random_value_max}" - elif args.magic_mp_random_percent: - flags += f" -mmprp {args.magic_mp_random_percent_min} {args.magic_mp_random_percent_max}" - - return flags - -def options(args): - - mp = "Original" - if args.magic_mp_shuffle: - mp = "Shuffle" - elif args.magic_mp_random_value: - mp = f"Random Value {args.magic_mp_random_value_min}-{args.magic_mp_random_value_max}" - elif args.magic_mp_random_percent: - mp = f"Random Percent {args.magic_mp_random_percent_min}-{args.magic_mp_random_percent_max}%" - - return [ - ("MP", mp, "misc_magic_mp"), - ] - -def menu(args): - entries = options(args) - for index, entry in enumerate(entries): - key, value, unique_name = entry - try: - if key == "MP": - value = value.replace("Random Value ", "") - value = value.replace("Random Percent ", "") - entries[index] = (key, value, unique_name) - except: - pass - return (name(), entries) - -def log(args): - from log import format_option - log = [name()] - - entries = options(args) - for entry in entries: - log.append(format_option(*entry)) - - return log +def name(): + return "Misc. Magic" + +def parse(parser): + magic = parser.add_argument_group("Misc. Magic") + + magic_mp = magic.add_mutually_exclusive_group() + magic_mp.add_argument("-mmps", "--magic-mp-shuffle", action = "store_true", + help = "Magic spells' MP costs shuffled") + magic_mp.add_argument("-mmprv", "--magic-mp-random-value", default = None, type = int, + nargs = 2, metavar = ("MIN", "MAX"), choices = range(255), + help = "Magic spells' MP costs randomized") + magic_mp.add_argument("-mmprp", "--magic-mp-random-percent", default = None, type = int, + nargs = 2, metavar = ("MIN", "MAX"), choices = range(201), + help = "Each Magic spell's MP cost set to random percent of original within given range") + +def process(args): + args._process_min_max("magic_mp_random_value") + args._process_min_max("magic_mp_random_percent") + +def flags(args): + flags = "" + + if args.magic_mp_shuffle: + flags += " -mmps" + elif args.magic_mp_random_value: + flags += f" -mmprv {args.magic_mp_random_value_min} {args.magic_mp_random_value_max}" + elif args.magic_mp_random_percent: + flags += f" -mmprp {args.magic_mp_random_percent_min} {args.magic_mp_random_percent_max}" + + return flags + +def options(args): + + mp = "Original" + if args.magic_mp_shuffle: + mp = "Shuffle" + elif args.magic_mp_random_value: + mp = f"Random Value {args.magic_mp_random_value_min}-{args.magic_mp_random_value_max}" + elif args.magic_mp_random_percent: + mp = f"Random Percent {args.magic_mp_random_percent_min}-{args.magic_mp_random_percent_max}%" + + return [ + ("MP", mp, "misc_magic_mp"), + ] + +def menu(args): + entries = options(args) + for index, entry in enumerate(entries): + key, value, unique_name = entry + try: + if key == "MP": + value = value.replace("Random Value ", "") + value = value.replace("Random Percent ", "") + entries[index] = (key, value, unique_name) + except AttributeError: + pass # value is not a string (e.g. a submenu), leave entry unchanged + return (name(), entries) + +def log(args): + from log import format_option + log = [name()] + + entries = options(args) + for entry in entries: + log.append(format_option(*entry)) + + return log diff --git a/args/objectives.py b/args/objectives.py index 64412c19..f6d858f9 100644 --- a/args/objectives.py +++ b/args/objectives.py @@ -1,4 +1,8 @@ from constants.objectives import MAX_OBJECTIVES, MAX_CONDITIONS +import sys + +def name(): + return "Objectives" def parse(parser): objectives = parser.add_argument_group("Objectives") @@ -63,7 +67,6 @@ def __init__(self, letter, result, conditions, conditions_required_min, conditio for arg in result_args: if arg not in result_type.value_range: - import sys args.parser.print_usage() print(f"{sys.argv[0]}: error: {result_type.name}: invalid argument {arg}") sys.exit(1) @@ -92,7 +95,6 @@ def __init__(self, letter, result, conditions, conditions_required_min, conditio for arg in condition_args: if arg not in condition_type.value_range: - import sys args.parser.print_usage() print(f"{sys.argv[0]}: error: {condition_type.name}: invalid argument {arg}") sys.exit(1) @@ -154,4 +156,4 @@ def log(args): lentries.append(entry) from log import section_entries - section_entries("Objectives", lentries, rentries) + section_entries(name(), lentries, rentries) diff --git a/args/scaling.py b/args/scaling.py index ed64f458..3213dabd 100644 --- a/args/scaling.py +++ b/args/scaling.py @@ -30,7 +30,7 @@ def parse(parser): hp_mp_scaling = scaling.add_mutually_exclusive_group() hp_mp_scaling.add_argument("-hma", "--hp-mp-scaling-average", default = None, type = float, metavar = ("VALUE"), choices = [x / 10.0 for x in range(5, 55, 5)], - help = "Enemy and boss hp/mp scales %(metavar)s * party averaage level") + help = "Enemy and boss hp/mp scales %(metavar)s * party average level") hp_mp_scaling.add_argument("-hmh", "--hp-mp-scaling-highest", default = None, type = float, metavar = ("VALUE"), choices = [x / 10.0 for x in range(5, 55, 5)], help = "Enemy and boss hp/mp scales %(metavar)s * highest level in party") @@ -53,7 +53,7 @@ def parse(parser): xp_gp_scaling = scaling.add_mutually_exclusive_group() xp_gp_scaling.add_argument("-xga", "--xp-gp-scaling-average", default = None, type = float, metavar = ("VALUE"), choices = [x / 10.0 for x in range(5, 55, 5)], - help = "Enemy and boss exp/gp scales %(metavar)s * party averaage level") + help = "Enemy and boss exp/gp scales %(metavar)s * party average level") xp_gp_scaling.add_argument("-xgh", "--xp-gp-scaling-highest", default = None, type = float, metavar = ("VALUE"), choices = [x / 10.0 for x in range(5, 55, 5)], help = "Enemy and boss exp/gp scales %(metavar)s * highest level in party") @@ -310,8 +310,8 @@ def menu(args): value = value.replace("Characters + Espers", "C + E") value = value.replace("Bosses + Dragons", "B + D") entries[index] = (key, value, unique_name) - except: - pass + except AttributeError: + pass # value is not a string (e.g. a submenu), leave entry unchanged return (name(), entries) def log(args): diff --git a/args/sketch_control.py b/args/sketch_control.py index 2ca8ebb4..59bf3fde 100644 --- a/args/sketch_control.py +++ b/args/sketch_control.py @@ -1,56 +1,56 @@ -def name(): - return "Sketch/Control" - -def parse(parser): - sketch_control = parser.add_argument_group("Sketch/Control") - - sketch_control.add_argument("-scis", "--sketch-control-improved-stats", action = "store_true", - help = "Sketch & Control 100%% accurate and use Sketcher/Controller's stats") - sketch_control.add_argument("-scia", "--sketch-control-improved-abilities", action = "store_true", - help = "Improves Sketch & Control abilities. Removes Battle from Sketch. Adds Rage as a Sketch/Control possibility for most monsters. Gives Sketch abilities to most bosses.") - -def process(args): - pass - -def flags(args): - flags = "" - - if args.sketch_control_improved_stats: - flags += " -scis" - if args.sketch_control_improved_abilities: - flags += " -scia" - - return flags - -def options(args): - abilities = "Improved" if args.sketch_control_improved_abilities else "Original" - accuracy = "100%" if args.sketch_control_improved_stats else "Original" - stats = "Character" if args.sketch_control_improved_stats else "Original" - - sketch_abilities = ("Sketch Ability", abilities, "sketch_abilities") - sketch_stats = ("Sketch Stats", stats, "sketch_stats") - sketch_accuracy = ("Sketch Accuracy", accuracy, "sketch_accuracy") - - control_abilities = ("Control Ability", abilities, "control_abilities") - control_stats = ("Control Stats", stats, "control_stats") - - return [ - sketch_abilities, - sketch_accuracy, - sketch_stats, - control_abilities, - control_stats, - ] - -def menu(args): - return (name(), options(args)) - -def log(args): - from log import format_option - log = [name()] - - entries = options(args) - for entry in entries: - log.append(format_option(*entry)) - - return log +def name(): + return "Sketch/Control" + +def parse(parser): + sketch_control = parser.add_argument_group("Sketch/Control") + + sketch_control.add_argument("-scis", "--sketch-control-improved-stats", action = "store_true", + help = "Sketch & Control 100%% accurate and use Sketcher/Controller's stats") + sketch_control.add_argument("-scia", "--sketch-control-improved-abilities", action = "store_true", + help = "Improves Sketch & Control abilities. Removes Battle from Sketch. Adds Rage as a Sketch/Control possibility for most monsters. Gives Sketch abilities to most bosses.") + +def process(args): + pass + +def flags(args): + flags = "" + + if args.sketch_control_improved_stats: + flags += " -scis" + if args.sketch_control_improved_abilities: + flags += " -scia" + + return flags + +def options(args): + abilities = "Improved" if args.sketch_control_improved_abilities else "Original" + accuracy = "100%" if args.sketch_control_improved_stats else "Original" + stats = "Character" if args.sketch_control_improved_stats else "Original" + + sketch_abilities = ("Sketch Ability", abilities, "sketch_abilities") + sketch_stats = ("Sketch Stats", stats, "sketch_stats") + sketch_accuracy = ("Sketch Accuracy", accuracy, "sketch_accuracy") + + control_abilities = ("Control Ability", abilities, "control_abilities") + control_stats = ("Control Stats", stats, "control_stats") + + return [ + sketch_abilities, + sketch_accuracy, + sketch_stats, + control_abilities, + control_stats, + ] + +def menu(args): + return (name(), options(args)) + +def log(args): + from log import format_option + log = [name()] + + entries = options(args) + for entry in entries: + log.append(format_option(*entry)) + + return log diff --git a/args/starting_gold_items.py b/args/starting_gold_items.py index e5ad3291..6a5d9de6 100644 --- a/args/starting_gold_items.py +++ b/args/starting_gold_items.py @@ -1,5 +1,3 @@ -import random - def name(): return "Starting Gold/Items" @@ -55,7 +53,7 @@ def __init__(self, _nid, min, max): item_id = 0 try: item_id = int(values[index]) - except: + except ValueError: args.parser.error(f"start-items: Failed to convert value into an int '{values[index]}'") if item_id < 0 or item_id >= 255: args.parser.error(f"start-items: '{item_id}' is an invalid value for an item id. It must be between 0-254") @@ -63,7 +61,7 @@ def __init__(self, _nid, min, max): min = 0 try: min = int(values[index + 1]) - except: + except ValueError: args.parser.error(f"start-items: Failed to convert value into an int '{values[index+1]}'") if min < 0 or min > 99: args.parser.error(f"start-items: '{min}' is an invalid min for an item. It must be between 0 and 99") @@ -71,7 +69,7 @@ def __init__(self, _nid, min, max): max = 0 try: max = int(values[index + 2]) - except: + except ValueError: args.parser.error(f"start-items: Failed to convert value into an int '{values[index+2]}'") if max <= 0 or max > 99: args.parser.error(f"start-items: '{max}' is an invalid count for an item. It must be between 1-99") diff --git a/args/starting_party.py b/args/starting_party.py index 6965c781..cfb20dbf 100644 --- a/args/starting_party.py +++ b/args/starting_party.py @@ -35,10 +35,12 @@ def process(args): args.start_chars = ["random"] else: # ensure only 4 starting characters and no duplicates (except random) - assert len(args.start_chars) <= 4 + if len(args.start_chars) > 4: + args.parser.error("starting-party: at most 4 starting characters allowed") start_chars_found = set() for char in args.start_chars: - assert (char == "random" or char == "randomngu" or char not in start_chars_found) + if char not in ("random", "randomngu") and char in start_chars_found: + args.parser.error(f"starting-party: duplicate starting character '{char}'") start_chars_found.add(char) def flags(args): diff --git a/battle/animations.py b/battle/animations.py index d6de4e52..7caab3a8 100644 --- a/battle/animations.py +++ b/battle/animations.py @@ -1,103 +1,103 @@ -from memory.space import Bank, Reserve, Read, Write -import data.battle_animation_scripts as battle_animation_scripts -import instruction.asm as asm -import args - -class Animations: - def __init__(self): - self.health_animation_reflect_mod() - self.stray_flash_mod() - - # Flash removal - replace_flash_animation = [] # The background flash to replace with monster flashes - remove_flash_animation = [] # The background flash addresses to remove - - if args.flashes_remove_most: - # Replace Boss Death and Final Kefka - replace_flash_animation.extend(["Boss Death", "Final KEFKA Death"]) - # And remove the rest - remove_flash_animation.extend(battle_animation_scripts.BATTLE_ANIMATION_FLASHES.keys()) - # Also removing critical flash - self.remove_critical_flash() - elif args.flashes_remove_worst: - replace_flash_animation.extend(["Boss Death"]) - remove_flash_animation.extend(["Ice 3", "Fire 3", "Bolt 3", "Schiller", "R.Polarity", "X-Zone", - "Muddle", "Dispel", "Shock", "Bum Rush", "Quadra Slam", "Slash", "Flash", - "Step Mine", "Rippler", "WallChange", "Ultima", "ForceField"]) - - # Replace any specified above - flash_address_arrays = [battle_animation_scripts.BATTLE_ANIMATION_FLASHES[name] for name in replace_flash_animation] - if flash_address_arrays: - self.replace_bg_flash_with_monster_flash_mod(flash_address_arrays) - - # Remove any remainder specified above - flash_address_arrays = [battle_animation_scripts.BATTLE_ANIMATION_FLASHES[name] for name in remove_flash_animation if name not in replace_flash_animation] - if flash_address_arrays: - self.remove_battle_flashes_mod(flash_address_arrays) - - def remove_critical_flash(self): - space = Reserve(0x23410, 0x23413, "Critical hit screen flash", asm.NOP()) - - def replace_bg_flash_with_monster_flash_mod(self, flash_address_arrays): - REPLACEMENTS = { - 0xAF: 0xB9, # Set background palette color subtraction (absolute) -> Set monster palettes color subtraction (absolute) - 0xB0: 0xBA, # Set background palette color addition (absolute) -> Set monster palettes color addition (absolute) - 0xB5: 0xBB, # Add color to background palette (relative) -> Add color to monster palettes (relative) - 0xB6: 0xBC, # Subtract color from background palette (relative) -> Subtract color from monster palettes (relative) - } - for flash_addresses in flash_address_arrays: - # For each address in its array - for flash_address in flash_addresses: - # Read the current animation command at the address - animation_cmd = Read(flash_address, flash_address+1) - if(animation_cmd[0] in REPLACEMENTS.keys()): - Write(flash_address, REPLACEMENTS[animation_cmd[0]], "BG flash to monster flash") - else: - # This is an error, reflecting a difference between the disassembly used to generate BATTLE_ANIMATION_FLASHES and the ROM - raise ValueError(f"Battle Animation Script Command at 0x{flash_address:x} (0x{animation_cmd[0]:x}) did not match an expected value.") - - def remove_battle_flashes_mod(self, flash_address_arrays): - ABSOLUTE_CHANGES = [0xb0, 0xaf] - RELATIVE_CHANGES = [0xb5, 0xb6] - # For each battle animation command - for flash_addresses in flash_address_arrays: - # For each address in its array - for flash_address in flash_addresses: - # Read the current animation command at the address - animation_cmd = Read(flash_address, flash_address+1) - if(animation_cmd[0] in ABSOLUTE_CHANGES): - # This is an absolute color change. To remove flashing effects, set the value to E0 to cause no background change - Write(flash_address+1, 0xE0, "Background color change (absolute)") - elif(animation_cmd[0] in RELATIVE_CHANGES): - # This is a relative color change. To remove flash effects, set the value to F0 to cause no background change - Write(flash_address+1, 0xF0, "Background color change (relative)") - else: - # This is an error, reflecting a difference between the disassembly used to generate BATTLE_ANIMATION_FLASHES and the ROM - raise ValueError(f"Battle Animation Script Command at 0x{flash_address:x} (0x{animation_cmd[0]:x}) did not match an expected value.") - - def stray_flash_mod(self): - # port of https://www.romhacking.net/hacks/6740/ - Write(0x10784b, 0xa7, "Flash tool position") #default: 0xaf - - def health_animation_reflect_mod(self): - # Ref: https://www.ff6hacking.com/forums/thread-4145.html - # Banon's Health command casts Cure 2 on the party with a unique animation. - # Because the animation is unique, it has the step-forward component built into it. - # And because Cure 2 can be reflected, if the command hits a mirrored target it will bounce and make Banon step forward again. - # Note: this only occurs if the whole party doesn't have reflect, only a subset. - # Used over and over, Banon can be made to walk completely off-screen. - # - # Fix: - # We tell the HEALTH animation to ignore block graphics, which prevents the reflect animation from playing. - # When encountering a reflection, the regular green Cure 2 animation will follow on the reflect recipient. - src = [ - asm.INC(0x62C0, asm.ABS), #Makes the animation ignore blocking graphics - asm.JSR(0xBC35, asm.ABS), #Call the subroutine that got displaced to inject the block override - asm.RTS() - ] - space = Write(Bank.C1, src, "Health animation fix") - jsrAddr = space.start_address - - # Replace the existing jump with one to our new service routine - space = Reserve(0x1BB67, 0x1BB69, "Health animation JSR") +from memory.space import Bank, Reserve, Read, Write +import data.battle_animation_scripts as battle_animation_scripts +import instruction.asm as asm +import args + +class Animations: + def __init__(self): + self.health_animation_reflect_mod() + self.stray_flash_mod() + + # Flash removal + replace_flash_animation = [] # The background flash to replace with monster flashes + remove_flash_animation = [] # The background flash addresses to remove + + if args.flashes_remove_most: + # Replace Boss Death and Final Kefka + replace_flash_animation.extend(["Boss Death", "Final KEFKA Death"]) + # And remove the rest + remove_flash_animation.extend(battle_animation_scripts.BATTLE_ANIMATION_FLASHES.keys()) + # Also removing critical flash + self.remove_critical_flash() + elif args.flashes_remove_worst: + replace_flash_animation.extend(["Boss Death"]) + remove_flash_animation.extend(["Ice 3", "Fire 3", "Bolt 3", "Schiller", "R.Polarity", "X-Zone", + "Muddle", "Dispel", "Shock", "Bum Rush", "Quadra Slam", "Slash", "Flash", + "Step Mine", "Rippler", "WallChange", "Ultima", "ForceField"]) + + # Replace any specified above + flash_address_arrays = [battle_animation_scripts.BATTLE_ANIMATION_FLASHES[name] for name in replace_flash_animation] + if flash_address_arrays: + self.replace_bg_flash_with_monster_flash_mod(flash_address_arrays) + + # Remove any remainder specified above + flash_address_arrays = [battle_animation_scripts.BATTLE_ANIMATION_FLASHES[name] for name in remove_flash_animation if name not in replace_flash_animation] + if flash_address_arrays: + self.remove_battle_flashes_mod(flash_address_arrays) + + def remove_critical_flash(self): + space = Reserve(0x23410, 0x23413, "Critical hit screen flash", asm.NOP()) + + def replace_bg_flash_with_monster_flash_mod(self, flash_address_arrays): + REPLACEMENTS = { + 0xAF: 0xB9, # Set background palette color subtraction (absolute) -> Set monster palettes color subtraction (absolute) + 0xB0: 0xBA, # Set background palette color addition (absolute) -> Set monster palettes color addition (absolute) + 0xB5: 0xBB, # Add color to background palette (relative) -> Add color to monster palettes (relative) + 0xB6: 0xBC, # Subtract color from background palette (relative) -> Subtract color from monster palettes (relative) + } + for flash_addresses in flash_address_arrays: + # For each address in its array + for flash_address in flash_addresses: + # Read the current animation command at the address + animation_cmd = Read(flash_address, flash_address+1) + if(animation_cmd[0] in REPLACEMENTS.keys()): + Write(flash_address, REPLACEMENTS[animation_cmd[0]], "BG flash to monster flash") + else: + # This is an error, reflecting a difference between the disassembly used to generate BATTLE_ANIMATION_FLASHES and the ROM + raise ValueError(f"Battle Animation Script Command at 0x{flash_address:x} (0x{animation_cmd[0]:x}) did not match an expected value.") + + def remove_battle_flashes_mod(self, flash_address_arrays): + ABSOLUTE_CHANGES = [0xb0, 0xaf] + RELATIVE_CHANGES = [0xb5, 0xb6] + # For each battle animation command + for flash_addresses in flash_address_arrays: + # For each address in its array + for flash_address in flash_addresses: + # Read the current animation command at the address + animation_cmd = Read(flash_address, flash_address+1) + if(animation_cmd[0] in ABSOLUTE_CHANGES): + # This is an absolute color change. To remove flashing effects, set the value to E0 to cause no background change + Write(flash_address+1, 0xE0, "Background color change (absolute)") + elif(animation_cmd[0] in RELATIVE_CHANGES): + # This is a relative color change. To remove flash effects, set the value to F0 to cause no background change + Write(flash_address+1, 0xF0, "Background color change (relative)") + else: + # This is an error, reflecting a difference between the disassembly used to generate BATTLE_ANIMATION_FLASHES and the ROM + raise ValueError(f"Battle Animation Script Command at 0x{flash_address:x} (0x{animation_cmd[0]:x}) did not match an expected value.") + + def stray_flash_mod(self): + # port of https://www.romhacking.net/hacks/6740/ + Write(0x10784b, 0xa7, "Flash tool position") #default: 0xaf + + def health_animation_reflect_mod(self): + # Ref: https://www.ff6hacking.com/forums/thread-4145.html + # Banon's Health command casts Cure 2 on the party with a unique animation. + # Because the animation is unique, it has the step-forward component built into it. + # And because Cure 2 can be reflected, if the command hits a mirrored target it will bounce and make Banon step forward again. + # Note: this only occurs if the whole party doesn't have reflect, only a subset. + # Used over and over, Banon can be made to walk completely off-screen. + # + # Fix: + # We tell the HEALTH animation to ignore block graphics, which prevents the reflect animation from playing. + # When encountering a reflection, the regular green Cure 2 animation will follow on the reflect recipient. + src = [ + asm.INC(0x62C0, asm.ABS), #Makes the animation ignore blocking graphics + asm.JSR(0xBC35, asm.ABS), #Call the subroutine that got displaced to inject the block override + asm.RTS() + ] + space = Write(Bank.C1, src, "Health animation fix") + jsrAddr = space.start_address + + # Replace the existing jump with one to our new service routine + space = Reserve(0x1BB67, 0x1BB69, "Health animation JSR") space.write(asm.JSR(jsrAddr, asm.ABS)) \ No newline at end of file diff --git a/battle/load_enemy_level.py b/battle/load_enemy_level.py index e317c44a..7ec6f30d 100644 --- a/battle/load_enemy_level.py +++ b/battle/load_enemy_level.py @@ -8,7 +8,7 @@ import objectives import args -enemy_level_address = 0x3b18 +from constants.battle_addresses import ENEMY_LEVEL as enemy_level_address class _LoadEnemyLevel: def __init__(self): diff --git a/battle/magitek_upgrade.py b/battle/magitek_upgrade.py index 928063b0..12fa16ff 100644 --- a/battle/magitek_upgrade.py +++ b/battle/magitek_upgrade.py @@ -1,101 +1,101 @@ -from memory.space import Bank, START_ADDRESS_SNES, Reserve, Write, Read -import instruction.asm as asm - -import data.event_bit as event_bit -import objectives - -class _MagitekUpgrade: - '''Set the Magitek menu in battle to match the Magitek Upgrade objective result.''' - def __init__(self): - # Write our 2 magitek tables - # We're moving them from C1/910C - C1/911B - # Default: Match Regular character's default - src = [ - 0x00, 0x01, #FIRE_BEAM, BOLT_BEAM, - 0x02, 0xFF, #ICE_BEAM, , - 0x04, 0xFF, #HEAL_FORCE, , - 0xFF, 0xFF #, - ] - space = Write(Bank.F0, src, "magitek default table") - magitek_default_table_addr = space.start_address - - # Upgraded: Match Terra's options - src = [ - 0x00, 0x01, #FIRE_BEAM, BOLT_BEAM, - 0x02, 0x03, #ICE_BEAM, BIO_BLAST, - 0x04, 0x05, #HEAL_FORCE, CONFUSER, - 0x06, 0x07 #X_FER, TEKMISSILE - ] - space = Write(Bank.F0, src, "magitek upgraded table") - magitek_upgraded_table_addr = space.start_address - - # Write our modifications to the C1 routines that use the - # magitek tables. - # There are 2 that use the magitek tables in C1: - # 1) C1/4D42 - C1/4D6D builds the magitek menu by writing to $575A & $5760 - # 2) C1/866A - C1/8683 used when selecting from the menu - - # stores the menu option in A. - self.magitek_upgrade_name = "Magitek Upgrade" - magitek_upgrade_name_upper = self.magitek_upgrade_name.upper() - - # 1) Build the magitek menu - branch_name = f"{magitek_upgrade_name_upper}_MENU" - src = self.get_branch_if_objective_complete_src(branch_name) - src += [ - f"NO_{branch_name}", - asm.LDA(START_ADDRESS_SNES + magitek_default_table_addr, asm.LNG_X), - asm.STA(0x575A, asm.ABS), - asm.LDA(START_ADDRESS_SNES + magitek_default_table_addr + 1, asm.LNG_X), - asm.STA(0x5760, asm.ABS), - asm.RTL(), - branch_name, - asm.LDA(START_ADDRESS_SNES + magitek_upgraded_table_addr, asm.LNG_X), - asm.STA(0x575A, asm.ABS), - asm.LDA(START_ADDRESS_SNES + magitek_upgraded_table_addr + 1, asm.LNG_X), - asm.STA(0x5760, asm.ABS), - asm.RTL(), - ] - space = Write(Bank.F0, src, "build magitek menu") - build_magitek_menu_addr = space.start_address - - space = Reserve(0x14d42, 0x14d6d, "build magitek menu jsl", asm.NOP()) - space.write( - asm.JSL(START_ADDRESS_SNES + build_magitek_menu_addr), - ) - - # 2) Select from the magitek menu - branch_name = f"{magitek_upgrade_name_upper}_SELECT" - src = self.get_branch_if_objective_complete_src(branch_name) - src += [ - f"NO_{branch_name}", - asm.LDA(START_ADDRESS_SNES + magitek_default_table_addr, asm.LNG_X), - asm.RTL(), - branch_name, - asm.LDA(START_ADDRESS_SNES + magitek_upgraded_table_addr, asm.LNG_X), - asm.RTL(), - ] - space = Write(Bank.F0, src, "select from magitek menu") - select_from_magitek_menu_addr_snes = space.start_address_snes - - space = Reserve(0x1866a, 0x18683, "select from magitek menu jsl", asm.NOP()) - space.write( - asm.JSL(select_from_magitek_menu_addr_snes), - ) - - def get_branch_if_objective_complete_src(self, branch_name): - src = [] - if self.magitek_upgrade_name in objectives.results: - for objective in objectives.results[self.magitek_upgrade_name]: - objective_event_bit = event_bit.objective(objective.id) - bit = event_bit.bit(objective_event_bit) - address = event_bit.address(objective_event_bit) - - src += [ - asm.LDA(address, asm.ABS), - asm.AND(2 ** bit, asm.IMM8), - asm.BNE(branch_name), - ] - return src - -magitek_upgrade = _MagitekUpgrade() - +from memory.space import Bank, START_ADDRESS_SNES, Reserve, Write, Read +import instruction.asm as asm + +import data.event_bit as event_bit +import objectives + +class _MagitekUpgrade: + '''Set the Magitek menu in battle to match the Magitek Upgrade objective result.''' + def __init__(self): + # Write our 2 magitek tables + # We're moving them from C1/910C - C1/911B + # Default: Match Regular character's default + src = [ + 0x00, 0x01, #FIRE_BEAM, BOLT_BEAM, + 0x02, 0xFF, #ICE_BEAM, , + 0x04, 0xFF, #HEAL_FORCE, , + 0xFF, 0xFF #, + ] + space = Write(Bank.F0, src, "magitek default table") + magitek_default_table_addr = space.start_address + + # Upgraded: Match Terra's options + src = [ + 0x00, 0x01, #FIRE_BEAM, BOLT_BEAM, + 0x02, 0x03, #ICE_BEAM, BIO_BLAST, + 0x04, 0x05, #HEAL_FORCE, CONFUSER, + 0x06, 0x07 #X_FER, TEKMISSILE + ] + space = Write(Bank.F0, src, "magitek upgraded table") + magitek_upgraded_table_addr = space.start_address + + # Write our modifications to the C1 routines that use the + # magitek tables. + # There are 2 that use the magitek tables in C1: + # 1) C1/4D42 - C1/4D6D builds the magitek menu by writing to $575A & $5760 + # 2) C1/866A - C1/8683 used when selecting from the menu - + # stores the menu option in A. + self.magitek_upgrade_name = "Magitek Upgrade" + magitek_upgrade_name_upper = self.magitek_upgrade_name.upper() + + # 1) Build the magitek menu + branch_name = f"{magitek_upgrade_name_upper}_MENU" + src = self.get_branch_if_objective_complete_src(branch_name) + src += [ + f"NO_{branch_name}", + asm.LDA(START_ADDRESS_SNES + magitek_default_table_addr, asm.LNG_X), + asm.STA(0x575A, asm.ABS), + asm.LDA(START_ADDRESS_SNES + magitek_default_table_addr + 1, asm.LNG_X), + asm.STA(0x5760, asm.ABS), + asm.RTL(), + branch_name, + asm.LDA(START_ADDRESS_SNES + magitek_upgraded_table_addr, asm.LNG_X), + asm.STA(0x575A, asm.ABS), + asm.LDA(START_ADDRESS_SNES + magitek_upgraded_table_addr + 1, asm.LNG_X), + asm.STA(0x5760, asm.ABS), + asm.RTL(), + ] + space = Write(Bank.F0, src, "build magitek menu") + build_magitek_menu_addr = space.start_address + + space = Reserve(0x14d42, 0x14d6d, "build magitek menu jsl", asm.NOP()) + space.write( + asm.JSL(START_ADDRESS_SNES + build_magitek_menu_addr), + ) + + # 2) Select from the magitek menu + branch_name = f"{magitek_upgrade_name_upper}_SELECT" + src = self.get_branch_if_objective_complete_src(branch_name) + src += [ + f"NO_{branch_name}", + asm.LDA(START_ADDRESS_SNES + magitek_default_table_addr, asm.LNG_X), + asm.RTL(), + branch_name, + asm.LDA(START_ADDRESS_SNES + magitek_upgraded_table_addr, asm.LNG_X), + asm.RTL(), + ] + space = Write(Bank.F0, src, "select from magitek menu") + select_from_magitek_menu_addr_snes = space.start_address_snes + + space = Reserve(0x1866a, 0x18683, "select from magitek menu jsl", asm.NOP()) + space.write( + asm.JSL(select_from_magitek_menu_addr_snes), + ) + + def get_branch_if_objective_complete_src(self, branch_name): + src = [] + if self.magitek_upgrade_name in objectives.results: + for objective in objectives.results[self.magitek_upgrade_name]: + objective_event_bit = event_bit.objective(objective.id) + bit = event_bit.bit(objective_event_bit) + address = event_bit.address(objective_event_bit) + + src += [ + asm.LDA(address, asm.ABS), + asm.AND(2 ** bit, asm.IMM8), + asm.BNE(branch_name), + ] + return src + +magitek_upgrade = _MagitekUpgrade() + diff --git a/battle/scaling.py b/battle/scaling.py index 27b2e502..77aeca59 100644 --- a/battle/scaling.py +++ b/battle/scaling.py @@ -1,6 +1,7 @@ from memory.space import Bank, Reserve, Write, Read from battle.scaling_functions import ScalingFunctions from battle.formation_flags import FormationFlag, formation_flags_address +import constants.battle_addresses as addresses import instruction.asm as asm import instruction.c2 as c2 @@ -10,10 +11,10 @@ class _Scaling(): def __init__(self): self.scaling_functions = ScalingFunctions() - self.enemy_level = 0x3b18 - self.level_scale = 0x3ecc - self.hp_mp_scale = 0x3ecd - self.xp_gp_scale = 0x3ece + self.enemy_level = addresses.ENEMY_LEVEL + self.level_scale = addresses.LEVEL_SCALE + self.hp_mp_scale = addresses.HP_MP_SCALE + self.xp_gp_scale = addresses.XP_GP_SCALE self.load_scale_levels_mod() self.scale_value_mod() @@ -185,7 +186,7 @@ def scale_xp_gp_mod(self): asm.STA(0xea, asm.DIR), asm.JMP(self.scale_value, asm.ABS), ] - space = Write(Bank.C2, src, "scale hp/mp") + space = Write(Bank.C2, src, "scale xp/gp") self.scale_xp_gp = space.start_address if args.xp_gp_scaling: diff --git a/bug_fixes/capture.py b/bug_fixes/capture.py index 603308bb..2ab04b4e 100644 --- a/bug_fixes/capture.py +++ b/bug_fixes/capture.py @@ -1,202 +1,202 @@ -from memory.space import Bank, Reserve, Write -import instruction.asm as asm -import args - -class Capture: - def __init__(self): - if args.fix_capture: - self.weapon_special_mod() - self.multisteal_mod() - - def multisteal_mod(self): - # Fixes issue with multiple steals caused by Genji Glove and/or Offering Capture. - # Issues resolved: - # 1) the stolen items are not all added to your inventory (only the last successful steal is actually added) - # 2) the message display window does not clear in between steal animations, - # meaning that the first item name is the one that is displayed for all subsequent successful steals. - # Based in part on https://www.angelfire.com/al2/imzogelmo/patches.html#patches's Multi-Steal Fix - # and Bropedio's Multi-Steal fix (https://www.ff6hacking.com/forums/thread-4124-post-40232.html#pid40232) - - # Custom variable locations - STOLEN_ITEM_ARRAY_START = 0x2f35 - STOLEN_ITEM_ARRAY_INDEX = 0x2f3b - - # Make the "Steal " text go through the array - src = [ - asm.REP(0x20), #Set A to 16 bits - asm.LDA(0x76, asm.DIR_16), #Load first two bytes of current animation entry - asm.CMP(0x0302, asm.IMM16), #Check for animation opcode 2 (upper text box) and text message 3 (Steal ) - asm.SEP(0x20), #Set A back to 8 bits - asm.BEQ("GET_STEAL_ITEM"), #If the above condition was true, branch - asm.LDA(0x2f35, asm.ABS), #Else, perform the displaced command (Note: it's unclear if this will ever get called) - asm.RTS(), # and return - "GET_STEAL_ITEM", - asm.SEP(0x10), #Set X to 8 bits - asm.LDX(STOLEN_ITEM_ARRAY_INDEX, asm.ABS), # Load the index to the stolen item array - asm.LDA(STOLEN_ITEM_ARRAY_START, asm.ABS_X), # Put the item from index into A - asm.REP(0x10), # Set X back to 16 bits - asm.INC(STOLEN_ITEM_ARRAY_INDEX, asm.ABS), # Increment the array index - asm.RTS() - ] - space = Write(Bank.C1, src, "Multisteal Fix: steal text") - c1_steal_print_addr = space.start_address - - # Call our new subroutine - space = Reserve(0x15f06, 0x15f08, "Multisteal Fix: call new C1 subroutine to load stolen item into A", asm.NOP()) - space.write( - asm.JSR(c1_steal_print_addr, asm.ABS) - ) - - #These two subroutines reset the stolen item index - src = [ - asm.JSR(0x1429, asm.ABS), # displaced instruction - asm.STZ(STOLEN_ITEM_ARRAY_INDEX, asm.ABS), # zero the index - asm.RTS() - ] - space = Write(Bank.C2, src, "Multisteal fix: reset stolen item array index routine") - stolen_item_index_reset = space.start_address - - space = Reserve(0x2140f, 0x21411, "Multisteal Fix: reset stolen item index") - space.write( - asm.JSR(stolen_item_index_reset, asm.ABS) - ) - - src = [ - asm.LDA(0xb5, asm.DIR), # displaced instruction - asm.ASL(), # displaced instruction - asm.STZ(STOLEN_ITEM_ARRAY_INDEX, asm.ABS), # zero the index - asm.RTS() - ] - space = Write(Bank.C2, src, "Multisteal fix: reset stolen item array index routine") - stolen_item_index_reset = space.start_address - - space = Reserve(0x213fa, 0x213fc, "Multisteal Fix: reset stolen item index") - space.write( - asm.JSR(stolen_item_index_reset, asm.ABS) - ) - - - - # New subroutine for storing acquired item - src = [ - asm.TSB(0x3a8c, asm.ABS), # set character's reserve item to be added - asm.LDA(0x32f4, asm.ABS_X), # load current reserve item - asm.PHA(), # save reserve item on stack - asm.XBA(), # get new item in A - asm.STA(0x32f4, asm.ABS_X), # store new item in reserve byte - # Store item in array for textbox - asm.PHX(), # save X - asm.LDX(STOLEN_ITEM_ARRAY_INDEX, asm.ABS), # Load the index for the array - asm.STA(STOLEN_ITEM_ARRAY_START, asm.ABS_X), # Store the item number into the array - asm.INC(STOLEN_ITEM_ARRAY_INDEX, asm.ABS), # Increment the index for highest variable stored - asm.PLX(), # restore X - # Done storing item in array for textbox - asm.PHX(), # save X - asm.JSR(0x62C7, asm.ABS), # add reserve to obtained-items buffer - asm.PLX(), # restore X - asm.PLA(), # restore previous reserve item - asm.STA(0x32f4, asm.ABS_X), # store in reserve item byte again - asm.RTS() - ] - space = Write(Bank.C2, src, "Multisteal Fix: store acquired item") - store_acquired_addr = space.start_address - - # Update steal formula where it stores the acquired item - space = Reserve(0x239e9, 0x239f4, "Multisteal Fix: call new subroutine", asm.NOP()) - space.write( - asm.XBA(), # store acquired item in B - asm.LDA(0x3018, asm.ABS_X), # character's unique bit - asm.JSR(store_acquired_addr, asm.ABS), # save new item to buffer - ) - - # Fix Item Return Buffer - space = Reserve(0x112d5, 0x112d7, "Multisteal Fix: avoid item return buffer overrun") - space.write( - asm.CPX(0x50, asm.IMM16) # the game only clears #$40 for item buffer, but it expects #$50 - ) - - def weapon_special_mod(self): - # http://assassin17.brinkster.net/patches.htm#anchor18 - NEW_SPECIAL_EFFECT_VAR = 0x2f3d - - ##### - # New subroutines - ##### - # Null the dog block [displaced Square code], and clear my custom special effect byte. - src = [ - asm.STA(0x3a83, asm.ABS), #Null Dog block - asm.STZ(NEW_SPECIAL_EFFECT_VAR, asm.ABS), #Clear new special effect variable - asm.RTS() - ] - space = Write(Bank.C2, src, "Capture Fix: null dog block") - null_dog_block_addr = space.start_address - - #Call Square's per-target special effect function as normal. Then call it again with - # a secondary variable so the Capture command can steal, unless the first function call - # already handled stealing. - src = [ - asm.PHP(), - asm.A8(), # Set 8 bit accumulator - asm.LDA(0x11a9, asm.ABS), # Load A with the current attack special effect -- based on table at c2/3dcd - asm.PHA(), - asm.JSR(0x387e, asm.ABS), # Call special effect function once for value in 11a9 - asm.LDA(NEW_SPECIAL_EFFECT_VAR, asm.ABS), - asm.CMP(0x1, asm.S), # does the custom match the original? - asm.BEQ("SKIP_IT"), # branch if so - asm.STA(0x11a9, asm.ABS), - asm.JSR(0x387e, asm.ABS), # Call special effect function again for our special effect var - "SKIP_IT", - asm.PLA(), - asm.STA(0x11a9, asm.ABS), - asm.PLP(), - asm.RTS() - ] - space = Write(Bank.C2, src, "Capture Fix: new special effect function") - new_special_effect_addr = space.start_address - - ##### - # Modify data in "Character Executes One Hit" function to use new subroutines and variable - ##### - space = Reserve(0x23185, 0x23187, "Capture Fix: call new null dog block subroutine")#, asm.NOP()) - space.write( - asm.JSR(null_dog_block_addr, asm.ABS) #(Null Dog block, then clear my custom special effect - # variable for Capture) - ) - space = Reserve(0x231b0, 0x231b2, "Capture Fix: Save Special Effect to new byte") - space.write( - asm.STA(NEW_SPECIAL_EFFECT_VAR, asm.ABS) #save special effect in our fancy new byte, so we won't - # overwrite the weapon's special effect. - ) - space = Reserve(0x2345c, 0x2345e, "Capture Fix: call new special effect function") - space.write( - asm.JSR(new_special_effect_addr, asm.ABS) #Special effect code for target .. customized - ) - - #### - # Dice Effect - #### - # FF6WC note: Rather than transfering Assassin's extensive changes made to the Dice Effect subroutine (C2/4168 - C2/41E5), - # which were seemingly made just to save space, I'm just transfering the main change as a subroutine: - # replacing the Capture animation with Dice with that of Fight starting at C2/41D9 - src = [ - asm.A8(), # Set 8 bit accumulator - asm.LDA(0xb5, asm.DIR), # Load Command Index - asm.CMP(0x00, asm.IMM8), # Maybe unnecessary? Compare Command with Fight - asm.BEQ("SET_ANIMATION"), # Branch if Fight command - asm.CMP(0x06, asm.IMM8), # Compare Command with Capture - asm.BNE("NO_CHANGE"), # Branch if not Capture command - "SET_ANIMATION", - asm.LDA(0x26, asm.IMM8), - asm.STA(0xb5, asm.DIR), # Store a dice toss animation - "NO_CHANGE", - asm.RTS() - ] - space = Write(Bank.C2, src, "Capture Fix: new dice toss animation") - dice_toss_animation_addr = space.start_address - - space = Reserve(0x241d9, 0x241e5, "Capture Fix: replace dice toss animation", asm.NOP()) - space.write( - asm.JSR(dice_toss_animation_addr, asm.ABS), #Jump to our new routine - asm.RTS() #Done - ) - +from memory.space import Bank, Reserve, Write +import instruction.asm as asm +import args + +class Capture: + def __init__(self): + if args.fix_capture: + self.weapon_special_mod() + self.multisteal_mod() + + def multisteal_mod(self): + # Fixes issue with multiple steals caused by Genji Glove and/or Offering Capture. + # Issues resolved: + # 1) the stolen items are not all added to your inventory (only the last successful steal is actually added) + # 2) the message display window does not clear in between steal animations, + # meaning that the first item name is the one that is displayed for all subsequent successful steals. + # Based in part on https://www.angelfire.com/al2/imzogelmo/patches.html#patches's Multi-Steal Fix + # and Bropedio's Multi-Steal fix (https://www.ff6hacking.com/forums/thread-4124-post-40232.html#pid40232) + + # Custom variable locations + STOLEN_ITEM_ARRAY_START = 0x2f35 + STOLEN_ITEM_ARRAY_INDEX = 0x2f3b + + # Make the "Steal " text go through the array + src = [ + asm.REP(0x20), #Set A to 16 bits + asm.LDA(0x76, asm.DIR_16), #Load first two bytes of current animation entry + asm.CMP(0x0302, asm.IMM16), #Check for animation opcode 2 (upper text box) and text message 3 (Steal ) + asm.SEP(0x20), #Set A back to 8 bits + asm.BEQ("GET_STEAL_ITEM"), #If the above condition was true, branch + asm.LDA(0x2f35, asm.ABS), #Else, perform the displaced command (Note: it's unclear if this will ever get called) + asm.RTS(), # and return + "GET_STEAL_ITEM", + asm.SEP(0x10), #Set X to 8 bits + asm.LDX(STOLEN_ITEM_ARRAY_INDEX, asm.ABS), # Load the index to the stolen item array + asm.LDA(STOLEN_ITEM_ARRAY_START, asm.ABS_X), # Put the item from index into A + asm.REP(0x10), # Set X back to 16 bits + asm.INC(STOLEN_ITEM_ARRAY_INDEX, asm.ABS), # Increment the array index + asm.RTS() + ] + space = Write(Bank.C1, src, "Multisteal Fix: steal text") + c1_steal_print_addr = space.start_address + + # Call our new subroutine + space = Reserve(0x15f06, 0x15f08, "Multisteal Fix: call new C1 subroutine to load stolen item into A", asm.NOP()) + space.write( + asm.JSR(c1_steal_print_addr, asm.ABS) + ) + + #These two subroutines reset the stolen item index + src = [ + asm.JSR(0x1429, asm.ABS), # displaced instruction + asm.STZ(STOLEN_ITEM_ARRAY_INDEX, asm.ABS), # zero the index + asm.RTS() + ] + space = Write(Bank.C2, src, "Multisteal fix: reset stolen item array index routine") + stolen_item_index_reset = space.start_address + + space = Reserve(0x2140f, 0x21411, "Multisteal Fix: reset stolen item index") + space.write( + asm.JSR(stolen_item_index_reset, asm.ABS) + ) + + src = [ + asm.LDA(0xb5, asm.DIR), # displaced instruction + asm.ASL(), # displaced instruction + asm.STZ(STOLEN_ITEM_ARRAY_INDEX, asm.ABS), # zero the index + asm.RTS() + ] + space = Write(Bank.C2, src, "Multisteal fix: reset stolen item array index routine") + stolen_item_index_reset = space.start_address + + space = Reserve(0x213fa, 0x213fc, "Multisteal Fix: reset stolen item index") + space.write( + asm.JSR(stolen_item_index_reset, asm.ABS) + ) + + + + # New subroutine for storing acquired item + src = [ + asm.TSB(0x3a8c, asm.ABS), # set character's reserve item to be added + asm.LDA(0x32f4, asm.ABS_X), # load current reserve item + asm.PHA(), # save reserve item on stack + asm.XBA(), # get new item in A + asm.STA(0x32f4, asm.ABS_X), # store new item in reserve byte + # Store item in array for textbox + asm.PHX(), # save X + asm.LDX(STOLEN_ITEM_ARRAY_INDEX, asm.ABS), # Load the index for the array + asm.STA(STOLEN_ITEM_ARRAY_START, asm.ABS_X), # Store the item number into the array + asm.INC(STOLEN_ITEM_ARRAY_INDEX, asm.ABS), # Increment the index for highest variable stored + asm.PLX(), # restore X + # Done storing item in array for textbox + asm.PHX(), # save X + asm.JSR(0x62C7, asm.ABS), # add reserve to obtained-items buffer + asm.PLX(), # restore X + asm.PLA(), # restore previous reserve item + asm.STA(0x32f4, asm.ABS_X), # store in reserve item byte again + asm.RTS() + ] + space = Write(Bank.C2, src, "Multisteal Fix: store acquired item") + store_acquired_addr = space.start_address + + # Update steal formula where it stores the acquired item + space = Reserve(0x239e9, 0x239f4, "Multisteal Fix: call new subroutine", asm.NOP()) + space.write( + asm.XBA(), # store acquired item in B + asm.LDA(0x3018, asm.ABS_X), # character's unique bit + asm.JSR(store_acquired_addr, asm.ABS), # save new item to buffer + ) + + # Fix Item Return Buffer + space = Reserve(0x112d5, 0x112d7, "Multisteal Fix: avoid item return buffer overrun") + space.write( + asm.CPX(0x50, asm.IMM16) # the game only clears #$40 for item buffer, but it expects #$50 + ) + + def weapon_special_mod(self): + # http://assassin17.brinkster.net/patches.htm#anchor18 + NEW_SPECIAL_EFFECT_VAR = 0x2f3d + + ##### + # New subroutines + ##### + # Null the dog block [displaced Square code], and clear my custom special effect byte. + src = [ + asm.STA(0x3a83, asm.ABS), #Null Dog block + asm.STZ(NEW_SPECIAL_EFFECT_VAR, asm.ABS), #Clear new special effect variable + asm.RTS() + ] + space = Write(Bank.C2, src, "Capture Fix: null dog block") + null_dog_block_addr = space.start_address + + #Call Square's per-target special effect function as normal. Then call it again with + # a secondary variable so the Capture command can steal, unless the first function call + # already handled stealing. + src = [ + asm.PHP(), + asm.A8(), # Set 8 bit accumulator + asm.LDA(0x11a9, asm.ABS), # Load A with the current attack special effect -- based on table at c2/3dcd + asm.PHA(), + asm.JSR(0x387e, asm.ABS), # Call special effect function once for value in 11a9 + asm.LDA(NEW_SPECIAL_EFFECT_VAR, asm.ABS), + asm.CMP(0x1, asm.S), # does the custom match the original? + asm.BEQ("SKIP_IT"), # branch if so + asm.STA(0x11a9, asm.ABS), + asm.JSR(0x387e, asm.ABS), # Call special effect function again for our special effect var + "SKIP_IT", + asm.PLA(), + asm.STA(0x11a9, asm.ABS), + asm.PLP(), + asm.RTS() + ] + space = Write(Bank.C2, src, "Capture Fix: new special effect function") + new_special_effect_addr = space.start_address + + ##### + # Modify data in "Character Executes One Hit" function to use new subroutines and variable + ##### + space = Reserve(0x23185, 0x23187, "Capture Fix: call new null dog block subroutine")#, asm.NOP()) + space.write( + asm.JSR(null_dog_block_addr, asm.ABS) #(Null Dog block, then clear my custom special effect + # variable for Capture) + ) + space = Reserve(0x231b0, 0x231b2, "Capture Fix: Save Special Effect to new byte") + space.write( + asm.STA(NEW_SPECIAL_EFFECT_VAR, asm.ABS) #save special effect in our fancy new byte, so we won't + # overwrite the weapon's special effect. + ) + space = Reserve(0x2345c, 0x2345e, "Capture Fix: call new special effect function") + space.write( + asm.JSR(new_special_effect_addr, asm.ABS) #Special effect code for target .. customized + ) + + #### + # Dice Effect + #### + # FF6WC note: Rather than transfering Assassin's extensive changes made to the Dice Effect subroutine (C2/4168 - C2/41E5), + # which were seemingly made just to save space, I'm just transfering the main change as a subroutine: + # replacing the Capture animation with Dice with that of Fight starting at C2/41D9 + src = [ + asm.A8(), # Set 8 bit accumulator + asm.LDA(0xb5, asm.DIR), # Load Command Index + asm.CMP(0x00, asm.IMM8), # Maybe unnecessary? Compare Command with Fight + asm.BEQ("SET_ANIMATION"), # Branch if Fight command + asm.CMP(0x06, asm.IMM8), # Compare Command with Capture + asm.BNE("NO_CHANGE"), # Branch if not Capture command + "SET_ANIMATION", + asm.LDA(0x26, asm.IMM8), + asm.STA(0xb5, asm.DIR), # Store a dice toss animation + "NO_CHANGE", + asm.RTS() + ] + space = Write(Bank.C2, src, "Capture Fix: new dice toss animation") + dice_toss_animation_addr = space.start_address + + space = Reserve(0x241d9, 0x241e5, "Capture Fix: replace dice toss animation", asm.NOP()) + space.write( + asm.JSR(dice_toss_animation_addr, asm.ABS), #Jump to our new routine + asm.RTS() #Done + ) + diff --git a/constants/battle_addresses.py b/constants/battle_addresses.py new file mode 100644 index 00000000..ca0d6e94 --- /dev/null +++ b/constants/battle_addresses.py @@ -0,0 +1,11 @@ +# battle wram addresses shared across modules +# kept in constants/ (side-effect free) so data/ modules can import them without +# triggering the rom writes that importing the battle package performs + +# level of each battle entity, indexed by entity slot (vanilla $3b18) +ENEMY_LEVEL = 0x3b18 + +# per-battle scale levels computed once by battle/scaling.py load_scale_levels_mod +LEVEL_SCALE = 0x3ecc +HP_MP_SCALE = 0x3ecd +XP_GP_SCALE = 0x3ece diff --git a/constants/standard_flags.py b/constants/standard_flags.py index 7eae3c7f..0b44d260 100644 --- a/constants/standard_flags.py +++ b/constants/standard_flags.py @@ -1,251 +1,251 @@ -# Standard Flags -# Last updated to match Ultros League Season 4 - -standard_flags = { - "game_mode": "Character Gating", - "spoiler_log": "False", - "Unlock Final Kefka": "", - "Unlock Final Kefka_Characters": "6-6", - "Unlock Final Kefka_Espers": "9-9", - "Unlock Final Kefka_conditions_req": "2-2", - "Unlock KT Skip": "", - "Unlock KT Skip_Characters": "9-9", - "Unlock KT Skip_Espers": "12-12", - "Unlock KT Skip_conditions_req": "1-1", - "Learn SwdTechs": "8-8", - "Learn SwdTechs_Check": "Doma Dream Awaken", - "Learn SwdTechs_conditions_req": "1-1", - "Magitek Upgrade": "", - "Magitek Upgrade_Check": "Magitek Factory Finish", - "Magitek Upgrade_conditions_req": "1-1", - "start_char1": "Random", - "start_char2": "Random", - "start_char3": "Random", - "start_char4": "None", - "fast_swdtech": "True", - "swdtechs_everyone_learns": "False", - "bum_rush_last": "True", - "blitzes_everyone_learns": "False", - "start_lores": "Random 3-5", - "lores_mp": "Random Percent 75-125%", - "lores_everyone_learns": "True", - "lvl_x_spells": "Original", - "start_rages": "Random 25-35", - "rages_no_leap": "True", - "rages_no_charm": "True", - "start_dances": "Random 1-2", - "dances_shuffle": "True", - "dances_display_abilities": "True", - "dances_no_stumble": "True", - "dances_everyone_learns": "False", - "steal_chances": "Higher", - "shuffle_steals_drops": "None", - "sketch_abilities": "Original", - "sketch_accuracy": "100%", - "sketch_stats": "Character", - "control_abilities": "Original", - "control_stats": "Character", - "start_average_level": "True", - "start_level": "3", - "start_naked": "False", - "equipable_umaro": "True", - "character_stats": "80-125%", - "Morph": "Random Unique", - "Steal": "Random Unique", - "SwdTech": "Random Unique", - "Throw": "Random Unique", - "Tools": "Random Unique", - "Blitz": "Random Unique", - "Runic": "Random Unique", - "Lore": "Random Unique", - "Sketch": "Random Unique", - "Slot": "Random Unique", - "Dance": "Random Unique", - "Rage": "Random Unique", - "Leap": "Random Unique", - "": "", - "shuffle_commands": "False", - "random_exclude_command1": "Possess", - "random_exclude_command2": "Shock", - "random_exclude_command3": "None", - "random_exclude_command4": "None", - "random_exclude_command5": "None", - "random_exclude_command6": "None", - "xp_mult": "3", - "mp_mult": "5", - "gp_mult": "5", - "no_exp_party_divide": "True", - "boss_battles": "Shuffle", - "dragon_battles": "Shuffle", - "statue_battles": "Mix", - "shuffle_random_phunbaba3": "False", - "boss_normalize_distort_stats": "False", - "boss_experience": "True", - "boss_no_undead": "True", - "boss_marshal_keep_lobos": "False", - "doom_gaze_no_escape": "True", - "wrexsoul_no_zinger": "True", - "magimaster_no_ultima": "True", - "chadarnook_more_demon": "True", - "level_scaling": "Characters + Espers + Dragons", - "level_scaling_factor": "2", - "hp_mp_scaling": "Characters + Espers + Dragons", - "hp_mp_scaling_factor": "2", - "xp_gp_scaling": "Characters + Espers + Dragons", - "xp_gp_scaling_factor": "2", - "ability_scaling": "Element", - "ability_scaling_factor": "2", - "max_scale_level": "40", - "scale_eight_dragons": "True", - "scale_final_battles": "False", - "random_encounters": "Shuffle", - "fixed_encounters": "Random", - "fixed_encounters_random": "0%", - "escapable": "100%", - "starting_espers": "0-0", - "spells": "Random 2-5", - "bonuses": "Random", - "esper_bonus_chance": "82%", - "esper_mp": "Random Percent 75-125%", - "esper_equipable": "All", - "esper_multi_summon": "False", - "esper_mastered_icon": "False", - "misc_magic_mp": "Random Percent 75-125%", - "natural_magic1": "Random", - "random_natural_levels1": "True", - "random_natural_spells1": "True", - "natural_magic2": "Random", - "random_natural_levels2": "True", - "random_natural_spells2": "True", - "natural_magic_menu_indicator": "True", - "gold": "5000", - "start_moogle_charms": "3", - "start_sprint_shoes": "0", - "start_warp_stones": "0", - "start_fenix_downs": "0", - "start_tools": "1", - "items_equipable": "Original + Random 33%", - "relics_equipable": "Original + Random 33%", - "cursed_shield_battles": "3-14", - "moogle_charm_all": "True", - "swdtech_runic_all": "True", - "stronger_atma_weapon": "True", - "shops_inventory": "Shuffle + Random", - "shops_random_percent": "20%", - "price": "Random Percent 75-125%", - "sell_fraction": "1/2", - "shop_dried_meat": "5", - "no_priceless_items": "True", - "shops_no_breakable_rods": "False", - "shops_expensive_breakable_rods": "True", - "shops_no_elemental_shields": "False", - "shops_no_super_balls": "False", - "shops_expensive_super_balls": "True", - "shops_no_exp_eggs": "False", - "shops_no_illuminas": "False", - "contents_value": "Shuffle + Random", - "chest_contents_shuffle_random_percent": "20%", - "chest_random_monsters_enemy": "0%", - "chest_random_monsters_boss": "0%", - "chest_monsters_shuffle": "True", - "palette_0": "Original 0", - "palette_1": "Original 1", - "palette_2": "Original 2", - "palette_3": "Original 3", - "palette_4": "Original 4", - "palette_5": "Original 5", - "palette_6": "Original 6", - "sprite_14": "Soldier", - "palette_14": "Palette 1", - "portraits_14": "Imp", - "sprite_15": "Imp", - "palette_15": "Palette 0", - "sprite_16": "General Leo", - "palette_16": "Palette 6", - "sprite_17": "Banon-Duncan", - "palette_17": "Palette 1", - "sprite_18": "Esper Terra", - "palette_18": "Palette 0", - "sprite_19": "Merchant", - "palette_19": "Palette 3", - "remove_flashes": "Worst", - "world_minimap": "High Contrast", - "healing_text": "Original", - "char_name_0": "Terra", - "char_sprite_0": "Terra", - "char_palette_0": "Palette 2", - "char_name_1": "Locke", - "char_sprite_1": "Locke", - "char_palette_1": "Palette 1", - "char_name_2": "Cyan", - "char_sprite_2": "Cyan", - "char_palette_2": "Palette 4", - "char_name_3": "Shadow", - "char_sprite_3": "Shadow", - "char_palette_3": "Palette 4", - "char_name_4": "Edgar", - "char_sprite_4": "Edgar", - "char_palette_4": "Palette 0", - "char_name_5": "Sabin", - "char_sprite_5": "Sabin", - "char_palette_5": "Palette 0", - "char_name_6": "Celes", - "char_sprite_6": "Celes", - "char_palette_6": "Palette 0", - "char_name_7": "Strago", - "char_sprite_7": "Strago", - "char_palette_7": "Palette 3", - "char_name_8": "Relm", - "char_sprite_8": "Relm", - "char_palette_8": "Palette 3", - "char_name_9": "Setzer", - "char_sprite_9": "Setzer", - "char_palette_9": "Palette 4", - "char_name_10": "Mog", - "char_sprite_10": "Mog", - "char_palette_10": "Palette 5", - "char_name_11": "Gau", - "char_sprite_11": "Gau", - "char_palette_11": "Palette 3", - "char_name_12": "Gogo", - "char_sprite_12": "Gogo", - "char_palette_12": "Palette 3", - "char_name_13": "Umaro", - "char_sprite_13": "Umaro", - "char_palette_13": "Palette 5", - "opponents": "Random", - "rewards": "Random", - "rewards_visible": "80-100", - "coliseum_no_exp_eggs": "False", - "coliseum_no_illuminas": "False", - "auction_random_items": "True", - "auction_no_chocobo_airship": "True", - "auction_door_esper_hint": "True", - "auction_max_espers": "1", - "movement": "AUTO_SPRINT", - "original_name_display": "True", - "random_rng": "True", - "scan_all": "False", - "warp_all": "False", - "event_timers": "None", - "y_npc": "None", - "npc_dialog_tips": "False", - "no_moogle_charms": "True", - "no_exp_eggs": "False", - "no_illuminas": "False", - "no_sprint_shoes": "True", - "no_free_paladin_shields": "True", - "no_free_characters_espers": "False", - "permadeath": "False", - "ultima": "N/A", - "remove_learnable_spell_ids": "", - "rls_0": "Ultima", - "fix_sketch": "True", - "fix_evade": "True", - "fix_vanish_doom": "True", - "fix_retort": "True", - "fix_jump": "True", - "fix_boss_skip": "True", - "fix_enemy_damage_counter": "True", - "fix_capture": "True", +# Standard Flags +# Last updated to match Ultros League Season 4 + +standard_flags = { + "game_mode": "Character Gating", + "spoiler_log": "False", + "Unlock Final Kefka": "", + "Unlock Final Kefka_Characters": "6-6", + "Unlock Final Kefka_Espers": "9-9", + "Unlock Final Kefka_conditions_req": "2-2", + "Unlock KT Skip": "", + "Unlock KT Skip_Characters": "9-9", + "Unlock KT Skip_Espers": "12-12", + "Unlock KT Skip_conditions_req": "1-1", + "Learn SwdTechs": "8-8", + "Learn SwdTechs_Check": "Doma Dream Awaken", + "Learn SwdTechs_conditions_req": "1-1", + "Magitek Upgrade": "", + "Magitek Upgrade_Check": "Magitek Factory Finish", + "Magitek Upgrade_conditions_req": "1-1", + "start_char1": "Random", + "start_char2": "Random", + "start_char3": "Random", + "start_char4": "None", + "fast_swdtech": "True", + "swdtechs_everyone_learns": "False", + "bum_rush_last": "True", + "blitzes_everyone_learns": "False", + "start_lores": "Random 3-5", + "lores_mp": "Random Percent 75-125%", + "lores_everyone_learns": "True", + "lvl_x_spells": "Original", + "start_rages": "Random 25-35", + "rages_no_leap": "True", + "rages_no_charm": "True", + "start_dances": "Random 1-2", + "dances_shuffle": "True", + "dances_display_abilities": "True", + "dances_no_stumble": "True", + "dances_everyone_learns": "False", + "steal_chances": "Higher", + "shuffle_steals_drops": "None", + "sketch_abilities": "Original", + "sketch_accuracy": "100%", + "sketch_stats": "Character", + "control_abilities": "Original", + "control_stats": "Character", + "start_average_level": "True", + "start_level": "3", + "start_naked": "False", + "equipable_umaro": "True", + "character_stats": "80-125%", + "Morph": "Random Unique", + "Steal": "Random Unique", + "SwdTech": "Random Unique", + "Throw": "Random Unique", + "Tools": "Random Unique", + "Blitz": "Random Unique", + "Runic": "Random Unique", + "Lore": "Random Unique", + "Sketch": "Random Unique", + "Slot": "Random Unique", + "Dance": "Random Unique", + "Rage": "Random Unique", + "Leap": "Random Unique", + "": "", + "shuffle_commands": "False", + "random_exclude_command1": "Possess", + "random_exclude_command2": "Shock", + "random_exclude_command3": "None", + "random_exclude_command4": "None", + "random_exclude_command5": "None", + "random_exclude_command6": "None", + "xp_mult": "3", + "mp_mult": "5", + "gp_mult": "5", + "no_exp_party_divide": "True", + "boss_battles": "Shuffle", + "dragon_battles": "Shuffle", + "statue_battles": "Mix", + "shuffle_random_phunbaba3": "False", + "boss_normalize_distort_stats": "False", + "boss_experience": "True", + "boss_no_undead": "True", + "boss_marshal_keep_lobos": "False", + "doom_gaze_no_escape": "True", + "wrexsoul_no_zinger": "True", + "magimaster_no_ultima": "True", + "chadarnook_more_demon": "True", + "level_scaling": "Characters + Espers + Dragons", + "level_scaling_factor": "2", + "hp_mp_scaling": "Characters + Espers + Dragons", + "hp_mp_scaling_factor": "2", + "xp_gp_scaling": "Characters + Espers + Dragons", + "xp_gp_scaling_factor": "2", + "ability_scaling": "Element", + "ability_scaling_factor": "2", + "max_scale_level": "40", + "scale_eight_dragons": "True", + "scale_final_battles": "False", + "random_encounters": "Shuffle", + "fixed_encounters": "Random", + "fixed_encounters_random": "0%", + "escapable": "100%", + "starting_espers": "0-0", + "spells": "Random 2-5", + "bonuses": "Random", + "esper_bonus_chance": "82%", + "esper_mp": "Random Percent 75-125%", + "esper_equipable": "All", + "esper_multi_summon": "False", + "esper_mastered_icon": "False", + "misc_magic_mp": "Random Percent 75-125%", + "natural_magic1": "Random", + "random_natural_levels1": "True", + "random_natural_spells1": "True", + "natural_magic2": "Random", + "random_natural_levels2": "True", + "random_natural_spells2": "True", + "natural_magic_menu_indicator": "True", + "gold": "5000", + "start_moogle_charms": "3", + "start_sprint_shoes": "0", + "start_warp_stones": "0", + "start_fenix_downs": "0", + "start_tools": "1", + "items_equipable": "Original + Random 33%", + "relics_equipable": "Original + Random 33%", + "cursed_shield_battles": "3-14", + "moogle_charm_all": "True", + "swdtech_runic_all": "True", + "stronger_atma_weapon": "True", + "shops_inventory": "Shuffle + Random", + "shops_random_percent": "20%", + "price": "Random Percent 75-125%", + "sell_fraction": "1/2", + "shop_dried_meat": "5", + "no_priceless_items": "True", + "shops_no_breakable_rods": "False", + "shops_expensive_breakable_rods": "True", + "shops_no_elemental_shields": "False", + "shops_no_super_balls": "False", + "shops_expensive_super_balls": "True", + "shops_no_exp_eggs": "False", + "shops_no_illuminas": "False", + "contents_value": "Shuffle + Random", + "chest_contents_shuffle_random_percent": "20%", + "chest_random_monsters_enemy": "0%", + "chest_random_monsters_boss": "0%", + "chest_monsters_shuffle": "True", + "palette_0": "Original 0", + "palette_1": "Original 1", + "palette_2": "Original 2", + "palette_3": "Original 3", + "palette_4": "Original 4", + "palette_5": "Original 5", + "palette_6": "Original 6", + "sprite_14": "Soldier", + "palette_14": "Palette 1", + "portraits_14": "Imp", + "sprite_15": "Imp", + "palette_15": "Palette 0", + "sprite_16": "General Leo", + "palette_16": "Palette 6", + "sprite_17": "Banon-Duncan", + "palette_17": "Palette 1", + "sprite_18": "Esper Terra", + "palette_18": "Palette 0", + "sprite_19": "Merchant", + "palette_19": "Palette 3", + "remove_flashes": "Worst", + "world_minimap": "High Contrast", + "healing_text": "Original", + "char_name_0": "Terra", + "char_sprite_0": "Terra", + "char_palette_0": "Palette 2", + "char_name_1": "Locke", + "char_sprite_1": "Locke", + "char_palette_1": "Palette 1", + "char_name_2": "Cyan", + "char_sprite_2": "Cyan", + "char_palette_2": "Palette 4", + "char_name_3": "Shadow", + "char_sprite_3": "Shadow", + "char_palette_3": "Palette 4", + "char_name_4": "Edgar", + "char_sprite_4": "Edgar", + "char_palette_4": "Palette 0", + "char_name_5": "Sabin", + "char_sprite_5": "Sabin", + "char_palette_5": "Palette 0", + "char_name_6": "Celes", + "char_sprite_6": "Celes", + "char_palette_6": "Palette 0", + "char_name_7": "Strago", + "char_sprite_7": "Strago", + "char_palette_7": "Palette 3", + "char_name_8": "Relm", + "char_sprite_8": "Relm", + "char_palette_8": "Palette 3", + "char_name_9": "Setzer", + "char_sprite_9": "Setzer", + "char_palette_9": "Palette 4", + "char_name_10": "Mog", + "char_sprite_10": "Mog", + "char_palette_10": "Palette 5", + "char_name_11": "Gau", + "char_sprite_11": "Gau", + "char_palette_11": "Palette 3", + "char_name_12": "Gogo", + "char_sprite_12": "Gogo", + "char_palette_12": "Palette 3", + "char_name_13": "Umaro", + "char_sprite_13": "Umaro", + "char_palette_13": "Palette 5", + "opponents": "Random", + "rewards": "Random", + "rewards_visible": "80-100", + "coliseum_no_exp_eggs": "False", + "coliseum_no_illuminas": "False", + "auction_random_items": "True", + "auction_no_chocobo_airship": "True", + "auction_door_esper_hint": "True", + "auction_max_espers": "1", + "movement": "AUTO_SPRINT", + "original_name_display": "True", + "random_rng": "True", + "scan_all": "False", + "warp_all": "False", + "event_timers": "None", + "y_npc": "None", + "npc_dialog_tips": "False", + "no_moogle_charms": "True", + "no_exp_eggs": "False", + "no_illuminas": "False", + "no_sprint_shoes": "True", + "no_free_paladin_shields": "True", + "no_free_characters_espers": "False", + "permadeath": "False", + "ultima": "N/A", + "remove_learnable_spell_ids": "", + "rls_0": "Ultima", + "fix_sketch": "True", + "fix_evade": "True", + "fix_vanish_doom": "True", + "fix_retort": "True", + "fix_jump": "True", + "fix_boss_skip": "True", + "fix_enemy_damage_counter": "True", + "fix_capture": "True", } \ No newline at end of file diff --git a/data/battle_animation_scripts.py b/data/battle_animation_scripts.py index 65ffb621..f0e41639 100644 --- a/data/battle_animation_scripts.py +++ b/data/battle_animation_scripts.py @@ -1,340 +1,340 @@ -# List of addresses within the Battle Animation Scripts for the following commands which cause screen flashes: -# B0 - Set background palette color addition (absolute) -# B5 - Add color to background palette (relative) -# AF - Set background palette color subtraction (absolute) -# B6 - Subtract color from background palette (relative) -# By changing address + 1 to E0 (for absolute) or F0 (for relative), it causes no change to the background color (that is, no flash) -BATTLE_ANIMATION_FLASHES = { - "Goner": [ - 0x100088, # AF E0 - set background color subtraction to 0 (black) - 0x10008C, # B6 61 - increase background color subtraction by 1 (red) - 0x100092, # B6 31 - decrease background color subtraction by 1 (yellow) - 0x100098, # B6 81 - increase background color subtraction by 1 (cyan) - 0x1000A1, # B6 91 - decrease background color subtraction by 1 (cyan) - 0x1000A3, # B6 21 - increase background color subtraction by 1 (yellow) - 0x1000D3, # B6 8F - increase background color subtraction by 15 (cyan) - 0x1000DF, # B0 FF - set background color addition to 31 (white) - 0x100172, # B5 F2 - decrease background color addition by 2 (white) - ], - "Final KEFKA Death": [ - 0x10023A, # B0 FF - set background color addition to 31 (white) - 0x100240, # B5 F4 - decrease background color addition by 4 (white) - 0x100248, # B0 FF - set background color addition to 31 (white) - 0x10024E, # B5 F4 - decrease background color addition by 4 (white) - ], - "Atom Edge": [ # Also True Edge - 0x1003D0, # AF E0 - set background color subtraction to 0 (black) - 0x1003DD, # B6 E1 - increase background color subtraction by 1 (black) - 0x1003E6, # B6 E1 - increase background color subtraction by 1 (black) - 0x10044B, # B6 F1 - decrease background color subtraction by 1 (black) - 0x100457, # B6 F1 - decrease background color subtraction by 1 (black) - ], - "Boss Death": [ - 0x100476, # B0 FF - set background color addition to 31 (white) - 0x10047C, # B5 F4 - decrease background color addition by 4 (white) - 0x100484, # B0 FF - set background color addition to 31 (white) - 0x100497, # B5 F4 - decrease background color addition by 4 (white) - ], - "Transform into Magicite": [ - 0x100F30, # B0 FF - set background color addition to 31 (white) - 0x100F3F, # B5 F2 - decrease background color addition by 2 (white) - 0x100F4E, # B5 F2 - decrease background color addition by 2 (white) - ], - "Purifier": [ - 0x101340, # AF E0 - set background color subtraction to 0 (black) - 0x101348, # B6 62 - increase background color subtraction by 2 (red) - 0x101380, # B6 81 - increase background color subtraction by 1 (cyan) - 0x10138A, # B6 F1 - decrease background color subtraction by 1 (black) - ], - "Wall": [ - 0x10177B, # AF E0 - set background color subtraction to 0 (black) - 0x10177F, # B6 61 - increase background color subtraction by 1 (red) - 0x101788, # B6 51 - decrease background color subtraction by 1 (magenta) - 0x101791, # B6 81 - increase background color subtraction by 1 (cyan) - 0x10179A, # B6 31 - decrease background color subtraction by 1 (yellow) - 0x1017A3, # B6 41 - increase background color subtraction by 1 (magenta) - 0x1017AC, # B6 91 - decrease background color subtraction by 1 (cyan) - 0x1017B5, # B6 51 - decrease background color subtraction by 1 (magenta) - ], - "Pearl": [ - 0x10190E, # B0 E0 - set background color addition to 0 (white) - 0x101913, # B5 E2 - increase background color addition by 2 (white) - 0x10191E, # B5 F1 - decrease background color addition by 1 (white) - 0x10193E, # B6 C2 - increase background color subtraction by 2 (blue) - ], - "Ice 3": [ - 0x101978, # B0 FF - set background color addition to 31 (white) - 0x10197B, # B5 F4 - decrease background color addition by 4 (white) - 0x10197E, # B5 F4 - decrease background color addition by 4 (white) - 0x101981, # B5 F4 - decrease background color addition by 4 (white) - 0x101984, # B5 F4 - decrease background color addition by 4 (white) - 0x101987, # B5 F4 - decrease background color addition by 4 (white) - 0x10198A, # B5 F4 - decrease background color addition by 4 (white) - 0x10198D, # B5 F4 - decrease background color addition by 4 (white) - 0x101990, # B5 F4 - decrease background color addition by 4 (white) - ], - "Fire 3": [ - 0x1019FA, # B0 9F - set background color addition to 31 (red) - 0x101A1C, # B5 94 - decrease background color addition by 4 (red) - ], - "Sleep": [ - 0x101A23, # AF E0 - set background color subtraction to 0 (black) - 0x101A29, # B6 E1 - increase background color subtraction by 1 (black) - 0x101A33, # B6 F1 - decrease background color subtraction by 1 (black) - ], - "7-Flush": [ - 0x101B43, # AF E0 - set background color subtraction to 0 (black) - 0x101B47, # B6 61 - increase background color subtraction by 1 (red) - 0x101B4D, # B6 51 - decrease background color subtraction by 1 (magenta) - 0x101B53, # B6 81 - increase background color subtraction by 1 (cyan) - 0x101B59, # B6 31 - decrease background color subtraction by 1 (yellow) - 0x101B5F, # B6 41 - increase background color subtraction by 1 (magenta) - 0x101B65, # B6 91 - decrease background color subtraction by 1 (cyan) - 0x101B6B, # B6 51 - decrease background color subtraction by 1 (magenta) - ], - "H-Bomb": [ - 0x101BC5, # B0 E0 - set background color addition to 0 (white) - 0x101BC9, # B5 E1 - increase background color addition by 1 (white) - 0x101C13, # B5 F1 - decrease background color addition by 1 (white) - ], - "Revenger": [ - 0x101C62, # AF E0 - set background color subtraction to 0 (black) - 0x101C66, # B6 81 - increase background color subtraction by 1 (cyan) - 0x101C6C, # B6 41 - increase background color subtraction by 1 (magenta) - 0x101C72, # B6 91 - decrease background color subtraction by 1 (cyan) - 0x101C78, # B6 21 - increase background color subtraction by 1 (yellow) - 0x101C7E, # B6 51 - decrease background color subtraction by 1 (magenta) - 0x101C84, # B6 81 - increase background color subtraction by 1 (cyan) - 0x101C86, # B6 31 - decrease background color subtraction by 1 (yellow) - 0x101C8C, # B6 91 - decrease background color subtraction by 1 (cyan) - ], - "Phantasm": [ - 0x101DFD, # AF E0 - set background color subtraction to 0 (black) - 0x101E03, # B6 E1 - increase background color subtraction by 1 (black) - 0x101E07, # B0 FF - set background color addition to 31 (white) - 0x101E0D, # B5 F4 - decrease background color addition by 4 (white) - 0x101E15, # B6 E2 - increase background color subtraction by 2 (black) - 0x101E1F, # B0 FF - set background color addition to 31 (white) - 0x101E27, # B5 F4 - decrease background color addition by 4 (white) - 0x101E2F, # B6 E2 - increase background color subtraction by 2 (black) - 0x101E3B, # B6 F1 - decrease background color subtraction by 1 (black) - ], - "TigerBreak": [ - 0x10240D, # B0 FF - set background color addition to 31 (white) - 0x102411, # B5 F2 - decrease background color addition by 2 (white) - 0x102416, # B5 F2 - decrease background color addition by 2 (white) - ], - "Metamorph": [ - 0x102595, # AF E0 - set background color subtraction to 0 (black) - 0x102599, # B6 61 - increase background color subtraction by 1 (red) - 0x1025AF, # B6 71 - decrease background color subtraction by 1 (red) - ], - "Cat Rain": [ - 0x102677, # B0 FF - set background color addition to 31 (white) - 0x10267B, # B5 F1 - decrease background color addition by 1 (white) - ], - "Charm": [ - 0x1026EE, # B0 FF - set background color addition to 31 (white) - 0x1026FB, # B5 F1 - decrease background color addition by 1 (white) - ], - "Mirager": [ - 0x102791, # B0 FF - set background color addition to 31 (white) - 0x102795, # B5 F2 - decrease background color addition by 2 (white) - ], - "SabreSoul": [ - 0x1027D3, # B0 FF - set background color addition to 31 (white) - 0x1027DA, # B5 F2 - decrease background color addition by 2 (white) - ], - "Back Blade": [ - 0x1028D3, # AF FF - set background color subtraction to 31 (black) - 0x1028DF, # B6 F4 - decrease background color subtraction by 4 (black) - ], - "RoyalShock": [ - 0x102967, # B0 FF - set background color addition to 31 (white) - 0x10296B, # B5 F2 - decrease background color addition by 2 (white) - 0x102973, # B5 F2 - decrease background color addition by 2 (white) - ], - "Overcast": [ - 0x102C3A, # AF E0 - set background color subtraction to 0 (black) - 0x102C55, # B6 E1 - increase background color subtraction by 1 (black) - 0x102C8D, # B6 F1 - decrease background color subtraction by 1 (black) - 0x102C91, # B6 F1 - decrease background color subtraction by 1 (black) - ], - "Disaster": [ - 0x102CEE, # AF E0 - set background color subtraction to 0 (black) - 0x102CF2, # B6 E1 - increase background color subtraction by 1 (black) - 0x102D19, # B6 F1 - decrease background color subtraction by 1 (black) - ], - "ForceField": [ - 0x102D3A, # B0 E0 - set background color addition to 0 (white) - 0x102D48, # B5 E1 - increase background color addition by 1 (white) - 0x102D64, # B5 F1 - decrease background color addition by 1 (white) - ], - "Terra/Tritoch Lightning": [ - 0x102E05, # B0 E0 - set background color addition to 0 (white) - 0x102E09, # B5 81 - increase background color addition by 1 (red) - 0x102E24, # B5 61 - increase background color addition by 1 (cyan) - ], - "S. Cross": [ - 0x102EDA, # AF E0 - set background color subtraction to 0 (black) - 0x102EDE, # B6 E2 - increase background color subtraction by 2 (black) - 0x102FA8, # B6 F2 - decrease background color subtraction by 2 (black) - 0x102FB1, # B0 E0 - set background color addition to 0 (white) - 0x102FBE, # B5 E2 - increase background color addition by 2 (white) - 0x102FD9, # B5 F2 - decrease background color addition by 2 (white) - ], - "Mind Blast": [ - 0x102FED, # B0 E0 - set background color addition to 0 (white) - 0x102FF1, # B5 81 - increase background color addition by 1 (red) - 0x102FF7, # B5 91 - decrease background color addition by 1 (red) - 0x102FF9, # B5 21 - increase background color addition by 1 (blue) - 0x102FFF, # B5 31 - decrease background color addition by 1 (blue) - 0x103001, # B5 C1 - increase background color addition by 1 (yellow) - 0x103007, # B5 91 - decrease background color addition by 1 (red) - 0x10300D, # B5 51 - decrease background color addition by 1 (green) - 0x103015, # B5 E2 - increase background color addition by 2 (white) - 0x10301F, # B5 F1 - decrease background color addition by 1 (white) - ], - "Flare Star": [ - 0x1030F5, # B0 E0 - set background color addition to 0 (white) - 0x103106, # B5 81 - increase background color addition by 1 (red) - 0x10310D, # B5 E2 - increase background color addition by 2 (white) - 0x103123, # B5 71 - decrease background color addition by 1 (cyan) - 0x10312E, # B5 91 - decrease background color addition by 1 (red) - ], - "Quasar": [ - 0x1031D2, # AF E0 - set background color subtraction to 0 (black) - 0x1031D6, # B6 E1 - increase background color subtraction by 1 (black) - 0x1031FA, # B6 F1 - decrease background color subtraction by 1 (black) - ], - "R.Polarity": [ - 0x10328B, # B0 FF - set background color addition to 31 (white) - 0x103292, # B5 F1 - decrease background color addition by 1 (white) - ], - "Rippler": [ - 0x1033C6, # B0 FF - set background color addition to 31 (white) - 0x1033CA, # B5 F1 - decrease background color addition by 1 (white) - ], - "Step Mine": [ - 0x1034D9, # B0 FF - set background color addition to 31 (white) - 0x1034E0, # B5 F4 - decrease background color addition by 4 (white) - ], - "L.5 Doom": [ - 0x1035E6, # B0 FF - set background color addition to 31 (white) - 0x1035F6, # B5 F4 - decrease background color addition by 4 (white) - ], - "Megazerk": [ - 0x103757, # B0 80 - set background color addition to 0 (red) - 0x103761, # B5 82 - increase background color addition by 2 (red) - 0x10378F, # B5 92 - decrease background color addition by 2 (red) - 0x103795, # B5 92 - decrease background color addition by 2 (red) - 0x10379B, # B5 92 - decrease background color addition by 2 (red) - 0x1037A1, # B5 92 - decrease background color addition by 2 (red) - 0x1037A7, # B5 92 - decrease background color addition by 2 (red) - 0x1037AD, # B5 92 - decrease background color addition by 2 (red) - 0x1037B3, # B5 92 - decrease background color addition by 2 (red) - 0x1037B9, # B5 92 - decrease background color addition by 2 (red) - 0x1037C0, # B5 92 - decrease background color addition by 2 (red) - ], - "Schiller": [ - 0x103819, # B0 FF - set background color addition to 31 (white) - 0x10381D, # B5 F4 - decrease background color addition by 4 (white) - ], - "WallChange": [ - 0x10399E, # B0 FF - set background color addition to 31 (white) - 0x1039A3, # B5 F2 - decrease background color addition by 2 (white) - 0x1039A9, # B5 F2 - decrease background color addition by 2 (white) - 0x1039AF, # B5 F2 - decrease background color addition by 2 (white) - 0x1039B5, # B5 F2 - decrease background color addition by 2 (white) - 0x1039BB, # B5 F2 - decrease background color addition by 2 (white) - 0x1039C1, # B5 F2 - decrease background color addition by 2 (white) - 0x1039C7, # B5 F2 - decrease background color addition by 2 (white) - 0x1039CD, # B5 F2 - decrease background color addition by 2 (white) - 0x1039D4, # B5 F2 - decrease background color addition by 2 (white) - ], - "Ultima": [ - 0x1056CB, # AF 60 - set background color subtraction to 0 (red) - 0x1056CF, # B6 C2 - increase background color subtraction by 2 (blue) - 0x1056ED, # B0 FF - set background color addition to 31 (white) - 0x1056F5, # B5 F1 - decrease background color addition by 1 (white) - ], - "Bolt 3": [ # Also Giga Volt - 0x10588E, # B0 FF - set background color addition to 31 (white) - 0x105893, # B5 F4 - decrease background color addition by 4 (white) - 0x105896, # B5 F4 - decrease background color addition by 4 (white) - 0x105899, # B5 F4 - decrease background color addition by 4 (white) - 0x10589C, # B5 F4 - decrease background color addition by 4 (white) - 0x1058A1, # B5 F4 - decrease background color addition by 4 (white) - 0x1058A6, # B5 F4 - decrease background color addition by 4 (white) - 0x1058AB, # B5 F4 - decrease background color addition by 4 (white) - 0x1058B0, # B5 F4 - decrease background color addition by 4 (white) - ], - "X-Zone": [ - 0x105A5D, # B0 FF - set background color addition to 31 (white) - 0x105A6A, # B5 F2 - decrease background color addition by 2 (white) - 0x105A79, # B5 F2 - decrease background color addition by 2 (white) - ], - "Dispel": [ - 0x105DC2, # B0 FF - set background color addition to 31 (white) - 0x105DC9, # B5 F1 - decrease background color addition by 1 (white) - 0x105DD2, # B5 F1 - decrease background color addition by 1 (white) - 0x105DDB, # B5 F1 - decrease background color addition by 1 (white) - 0x105DE4, # B5 F1 - decrease background color addition by 1 (white) - 0x105DED, # B5 F1 - decrease background color addition by 1 (white) - ], - "Muddle": [ # Also L.3 Muddle, Confusion - 0x1060EA, # B0 FF - set background color addition to 31 (white) - 0x1060EE, # B5 F1 - decrease background color addition by 1 (white) - ], - "Shock": [ - 0x1068BE, # B0 FF - set background color addition to 31 (white) - 0x1068D0, # B5 F1 - decrease background color addition by 1 (white) - ], - "Bum Rush": [ - 0x106C3E, # B0 E0 - set background color addition to 0 (white) - 0x106C47, # B0 E0 - set background color addition to 0 (white) - 0x106C53, # B0 E0 - set background color addition to 0 (white) - 0x106C7E, # B0 FF - set background color addition to 31 (white) - 0x106C87, # B0 E0 - set background color addition to 0 (white) - 0x106C95, # B0 FF - set background color addition to 31 (white) - 0x106C9E, # B0 E0 - set background color addition to 0 (white) - ], - "Stunner": [ - 0x1071BA, # B0 20 - set background color addition to 0 (blue) - 0x1071C1, # B5 24 - increase background color addition by 4 (blue) - 0x1071CA, # B5 24 - increase background color addition by 4 (blue) - 0x1071D5, # B5 24 - increase background color addition by 4 (blue) - 0x1071DE, # B5 24 - increase background color addition by 4 (blue) - 0x1071E9, # B5 24 - increase background color addition by 4 (blue) - 0x1071F2, # B5 24 - increase background color addition by 4 (blue) - 0x1071FD, # B5 24 - increase background color addition by 4 (blue) - 0x107206, # B5 24 - increase background color addition by 4 (blue) - 0x107211, # B5 24 - increase background color addition by 4 (blue) - 0x10721A, # B5 24 - increase background color addition by 4 (blue) - 0x10725A, # B5 32 - decrease background color addition by 2 (blue) - ], - "Quadra Slam": [ # Also Quadra Slice - 0x1073DC, # B0 FF - set background color addition to 31 (white) - 0x1073EE, # B5 F2 - decrease background color addition by 2 (white) - 0x1073F3, # B5 F2 - decrease background color addition by 2 (white) - 0x107402, # B0 5F - set background color addition to 31 (green) - 0x107424, # B5 54 - decrease background color addition by 4 (green) - 0x107429, # B5 54 - decrease background color addition by 4 (green) - 0x107436, # B0 3F - set background color addition to 31 (blue) - 0x107458, # B5 34 - decrease background color addition by 4 (blue) - 0x10745D, # B5 34 - decrease background color addition by 4 (blue) - 0x107490, # B0 9F - set background color addition to 31 (red) - 0x1074B2, # B5 94 - decrease background color addition by 4 (red) - 0x1074B7, # B5 94 - decrease background color addition by 4 (red) - ], - "Slash": [ - 0x1074F4, # B0 FF - set background color addition to 31 (white) - 0x1074FD, # B5 F2 - decrease background color addition by 2 (white) - 0x107507, # B5 F2 - decrease background color addition by 2 (white) - ], - "Flash": [ - 0x107850, # B0 FF - set background color addition to 31 (white) - 0x10785C, # B5 F1 - decrease background color addition by 1 (white) - ] -} - +# List of addresses within the Battle Animation Scripts for the following commands which cause screen flashes: +# B0 - Set background palette color addition (absolute) +# B5 - Add color to background palette (relative) +# AF - Set background palette color subtraction (absolute) +# B6 - Subtract color from background palette (relative) +# By changing address + 1 to E0 (for absolute) or F0 (for relative), it causes no change to the background color (that is, no flash) +BATTLE_ANIMATION_FLASHES = { + "Goner": [ + 0x100088, # AF E0 - set background color subtraction to 0 (black) + 0x10008C, # B6 61 - increase background color subtraction by 1 (red) + 0x100092, # B6 31 - decrease background color subtraction by 1 (yellow) + 0x100098, # B6 81 - increase background color subtraction by 1 (cyan) + 0x1000A1, # B6 91 - decrease background color subtraction by 1 (cyan) + 0x1000A3, # B6 21 - increase background color subtraction by 1 (yellow) + 0x1000D3, # B6 8F - increase background color subtraction by 15 (cyan) + 0x1000DF, # B0 FF - set background color addition to 31 (white) + 0x100172, # B5 F2 - decrease background color addition by 2 (white) + ], + "Final KEFKA Death": [ + 0x10023A, # B0 FF - set background color addition to 31 (white) + 0x100240, # B5 F4 - decrease background color addition by 4 (white) + 0x100248, # B0 FF - set background color addition to 31 (white) + 0x10024E, # B5 F4 - decrease background color addition by 4 (white) + ], + "Atom Edge": [ # Also True Edge + 0x1003D0, # AF E0 - set background color subtraction to 0 (black) + 0x1003DD, # B6 E1 - increase background color subtraction by 1 (black) + 0x1003E6, # B6 E1 - increase background color subtraction by 1 (black) + 0x10044B, # B6 F1 - decrease background color subtraction by 1 (black) + 0x100457, # B6 F1 - decrease background color subtraction by 1 (black) + ], + "Boss Death": [ + 0x100476, # B0 FF - set background color addition to 31 (white) + 0x10047C, # B5 F4 - decrease background color addition by 4 (white) + 0x100484, # B0 FF - set background color addition to 31 (white) + 0x100497, # B5 F4 - decrease background color addition by 4 (white) + ], + "Transform into Magicite": [ + 0x100F30, # B0 FF - set background color addition to 31 (white) + 0x100F3F, # B5 F2 - decrease background color addition by 2 (white) + 0x100F4E, # B5 F2 - decrease background color addition by 2 (white) + ], + "Purifier": [ + 0x101340, # AF E0 - set background color subtraction to 0 (black) + 0x101348, # B6 62 - increase background color subtraction by 2 (red) + 0x101380, # B6 81 - increase background color subtraction by 1 (cyan) + 0x10138A, # B6 F1 - decrease background color subtraction by 1 (black) + ], + "Wall": [ + 0x10177B, # AF E0 - set background color subtraction to 0 (black) + 0x10177F, # B6 61 - increase background color subtraction by 1 (red) + 0x101788, # B6 51 - decrease background color subtraction by 1 (magenta) + 0x101791, # B6 81 - increase background color subtraction by 1 (cyan) + 0x10179A, # B6 31 - decrease background color subtraction by 1 (yellow) + 0x1017A3, # B6 41 - increase background color subtraction by 1 (magenta) + 0x1017AC, # B6 91 - decrease background color subtraction by 1 (cyan) + 0x1017B5, # B6 51 - decrease background color subtraction by 1 (magenta) + ], + "Pearl": [ + 0x10190E, # B0 E0 - set background color addition to 0 (white) + 0x101913, # B5 E2 - increase background color addition by 2 (white) + 0x10191E, # B5 F1 - decrease background color addition by 1 (white) + 0x10193E, # B6 C2 - increase background color subtraction by 2 (blue) + ], + "Ice 3": [ + 0x101978, # B0 FF - set background color addition to 31 (white) + 0x10197B, # B5 F4 - decrease background color addition by 4 (white) + 0x10197E, # B5 F4 - decrease background color addition by 4 (white) + 0x101981, # B5 F4 - decrease background color addition by 4 (white) + 0x101984, # B5 F4 - decrease background color addition by 4 (white) + 0x101987, # B5 F4 - decrease background color addition by 4 (white) + 0x10198A, # B5 F4 - decrease background color addition by 4 (white) + 0x10198D, # B5 F4 - decrease background color addition by 4 (white) + 0x101990, # B5 F4 - decrease background color addition by 4 (white) + ], + "Fire 3": [ + 0x1019FA, # B0 9F - set background color addition to 31 (red) + 0x101A1C, # B5 94 - decrease background color addition by 4 (red) + ], + "Sleep": [ + 0x101A23, # AF E0 - set background color subtraction to 0 (black) + 0x101A29, # B6 E1 - increase background color subtraction by 1 (black) + 0x101A33, # B6 F1 - decrease background color subtraction by 1 (black) + ], + "7-Flush": [ + 0x101B43, # AF E0 - set background color subtraction to 0 (black) + 0x101B47, # B6 61 - increase background color subtraction by 1 (red) + 0x101B4D, # B6 51 - decrease background color subtraction by 1 (magenta) + 0x101B53, # B6 81 - increase background color subtraction by 1 (cyan) + 0x101B59, # B6 31 - decrease background color subtraction by 1 (yellow) + 0x101B5F, # B6 41 - increase background color subtraction by 1 (magenta) + 0x101B65, # B6 91 - decrease background color subtraction by 1 (cyan) + 0x101B6B, # B6 51 - decrease background color subtraction by 1 (magenta) + ], + "H-Bomb": [ + 0x101BC5, # B0 E0 - set background color addition to 0 (white) + 0x101BC9, # B5 E1 - increase background color addition by 1 (white) + 0x101C13, # B5 F1 - decrease background color addition by 1 (white) + ], + "Revenger": [ + 0x101C62, # AF E0 - set background color subtraction to 0 (black) + 0x101C66, # B6 81 - increase background color subtraction by 1 (cyan) + 0x101C6C, # B6 41 - increase background color subtraction by 1 (magenta) + 0x101C72, # B6 91 - decrease background color subtraction by 1 (cyan) + 0x101C78, # B6 21 - increase background color subtraction by 1 (yellow) + 0x101C7E, # B6 51 - decrease background color subtraction by 1 (magenta) + 0x101C84, # B6 81 - increase background color subtraction by 1 (cyan) + 0x101C86, # B6 31 - decrease background color subtraction by 1 (yellow) + 0x101C8C, # B6 91 - decrease background color subtraction by 1 (cyan) + ], + "Phantasm": [ + 0x101DFD, # AF E0 - set background color subtraction to 0 (black) + 0x101E03, # B6 E1 - increase background color subtraction by 1 (black) + 0x101E07, # B0 FF - set background color addition to 31 (white) + 0x101E0D, # B5 F4 - decrease background color addition by 4 (white) + 0x101E15, # B6 E2 - increase background color subtraction by 2 (black) + 0x101E1F, # B0 FF - set background color addition to 31 (white) + 0x101E27, # B5 F4 - decrease background color addition by 4 (white) + 0x101E2F, # B6 E2 - increase background color subtraction by 2 (black) + 0x101E3B, # B6 F1 - decrease background color subtraction by 1 (black) + ], + "TigerBreak": [ + 0x10240D, # B0 FF - set background color addition to 31 (white) + 0x102411, # B5 F2 - decrease background color addition by 2 (white) + 0x102416, # B5 F2 - decrease background color addition by 2 (white) + ], + "Metamorph": [ + 0x102595, # AF E0 - set background color subtraction to 0 (black) + 0x102599, # B6 61 - increase background color subtraction by 1 (red) + 0x1025AF, # B6 71 - decrease background color subtraction by 1 (red) + ], + "Cat Rain": [ + 0x102677, # B0 FF - set background color addition to 31 (white) + 0x10267B, # B5 F1 - decrease background color addition by 1 (white) + ], + "Charm": [ + 0x1026EE, # B0 FF - set background color addition to 31 (white) + 0x1026FB, # B5 F1 - decrease background color addition by 1 (white) + ], + "Mirager": [ + 0x102791, # B0 FF - set background color addition to 31 (white) + 0x102795, # B5 F2 - decrease background color addition by 2 (white) + ], + "SabreSoul": [ + 0x1027D3, # B0 FF - set background color addition to 31 (white) + 0x1027DA, # B5 F2 - decrease background color addition by 2 (white) + ], + "Back Blade": [ + 0x1028D3, # AF FF - set background color subtraction to 31 (black) + 0x1028DF, # B6 F4 - decrease background color subtraction by 4 (black) + ], + "RoyalShock": [ + 0x102967, # B0 FF - set background color addition to 31 (white) + 0x10296B, # B5 F2 - decrease background color addition by 2 (white) + 0x102973, # B5 F2 - decrease background color addition by 2 (white) + ], + "Overcast": [ + 0x102C3A, # AF E0 - set background color subtraction to 0 (black) + 0x102C55, # B6 E1 - increase background color subtraction by 1 (black) + 0x102C8D, # B6 F1 - decrease background color subtraction by 1 (black) + 0x102C91, # B6 F1 - decrease background color subtraction by 1 (black) + ], + "Disaster": [ + 0x102CEE, # AF E0 - set background color subtraction to 0 (black) + 0x102CF2, # B6 E1 - increase background color subtraction by 1 (black) + 0x102D19, # B6 F1 - decrease background color subtraction by 1 (black) + ], + "ForceField": [ + 0x102D3A, # B0 E0 - set background color addition to 0 (white) + 0x102D48, # B5 E1 - increase background color addition by 1 (white) + 0x102D64, # B5 F1 - decrease background color addition by 1 (white) + ], + "Terra/Tritoch Lightning": [ + 0x102E05, # B0 E0 - set background color addition to 0 (white) + 0x102E09, # B5 81 - increase background color addition by 1 (red) + 0x102E24, # B5 61 - increase background color addition by 1 (cyan) + ], + "S. Cross": [ + 0x102EDA, # AF E0 - set background color subtraction to 0 (black) + 0x102EDE, # B6 E2 - increase background color subtraction by 2 (black) + 0x102FA8, # B6 F2 - decrease background color subtraction by 2 (black) + 0x102FB1, # B0 E0 - set background color addition to 0 (white) + 0x102FBE, # B5 E2 - increase background color addition by 2 (white) + 0x102FD9, # B5 F2 - decrease background color addition by 2 (white) + ], + "Mind Blast": [ + 0x102FED, # B0 E0 - set background color addition to 0 (white) + 0x102FF1, # B5 81 - increase background color addition by 1 (red) + 0x102FF7, # B5 91 - decrease background color addition by 1 (red) + 0x102FF9, # B5 21 - increase background color addition by 1 (blue) + 0x102FFF, # B5 31 - decrease background color addition by 1 (blue) + 0x103001, # B5 C1 - increase background color addition by 1 (yellow) + 0x103007, # B5 91 - decrease background color addition by 1 (red) + 0x10300D, # B5 51 - decrease background color addition by 1 (green) + 0x103015, # B5 E2 - increase background color addition by 2 (white) + 0x10301F, # B5 F1 - decrease background color addition by 1 (white) + ], + "Flare Star": [ + 0x1030F5, # B0 E0 - set background color addition to 0 (white) + 0x103106, # B5 81 - increase background color addition by 1 (red) + 0x10310D, # B5 E2 - increase background color addition by 2 (white) + 0x103123, # B5 71 - decrease background color addition by 1 (cyan) + 0x10312E, # B5 91 - decrease background color addition by 1 (red) + ], + "Quasar": [ + 0x1031D2, # AF E0 - set background color subtraction to 0 (black) + 0x1031D6, # B6 E1 - increase background color subtraction by 1 (black) + 0x1031FA, # B6 F1 - decrease background color subtraction by 1 (black) + ], + "R.Polarity": [ + 0x10328B, # B0 FF - set background color addition to 31 (white) + 0x103292, # B5 F1 - decrease background color addition by 1 (white) + ], + "Rippler": [ + 0x1033C6, # B0 FF - set background color addition to 31 (white) + 0x1033CA, # B5 F1 - decrease background color addition by 1 (white) + ], + "Step Mine": [ + 0x1034D9, # B0 FF - set background color addition to 31 (white) + 0x1034E0, # B5 F4 - decrease background color addition by 4 (white) + ], + "L.5 Doom": [ + 0x1035E6, # B0 FF - set background color addition to 31 (white) + 0x1035F6, # B5 F4 - decrease background color addition by 4 (white) + ], + "Megazerk": [ + 0x103757, # B0 80 - set background color addition to 0 (red) + 0x103761, # B5 82 - increase background color addition by 2 (red) + 0x10378F, # B5 92 - decrease background color addition by 2 (red) + 0x103795, # B5 92 - decrease background color addition by 2 (red) + 0x10379B, # B5 92 - decrease background color addition by 2 (red) + 0x1037A1, # B5 92 - decrease background color addition by 2 (red) + 0x1037A7, # B5 92 - decrease background color addition by 2 (red) + 0x1037AD, # B5 92 - decrease background color addition by 2 (red) + 0x1037B3, # B5 92 - decrease background color addition by 2 (red) + 0x1037B9, # B5 92 - decrease background color addition by 2 (red) + 0x1037C0, # B5 92 - decrease background color addition by 2 (red) + ], + "Schiller": [ + 0x103819, # B0 FF - set background color addition to 31 (white) + 0x10381D, # B5 F4 - decrease background color addition by 4 (white) + ], + "WallChange": [ + 0x10399E, # B0 FF - set background color addition to 31 (white) + 0x1039A3, # B5 F2 - decrease background color addition by 2 (white) + 0x1039A9, # B5 F2 - decrease background color addition by 2 (white) + 0x1039AF, # B5 F2 - decrease background color addition by 2 (white) + 0x1039B5, # B5 F2 - decrease background color addition by 2 (white) + 0x1039BB, # B5 F2 - decrease background color addition by 2 (white) + 0x1039C1, # B5 F2 - decrease background color addition by 2 (white) + 0x1039C7, # B5 F2 - decrease background color addition by 2 (white) + 0x1039CD, # B5 F2 - decrease background color addition by 2 (white) + 0x1039D4, # B5 F2 - decrease background color addition by 2 (white) + ], + "Ultima": [ + 0x1056CB, # AF 60 - set background color subtraction to 0 (red) + 0x1056CF, # B6 C2 - increase background color subtraction by 2 (blue) + 0x1056ED, # B0 FF - set background color addition to 31 (white) + 0x1056F5, # B5 F1 - decrease background color addition by 1 (white) + ], + "Bolt 3": [ # Also Giga Volt + 0x10588E, # B0 FF - set background color addition to 31 (white) + 0x105893, # B5 F4 - decrease background color addition by 4 (white) + 0x105896, # B5 F4 - decrease background color addition by 4 (white) + 0x105899, # B5 F4 - decrease background color addition by 4 (white) + 0x10589C, # B5 F4 - decrease background color addition by 4 (white) + 0x1058A1, # B5 F4 - decrease background color addition by 4 (white) + 0x1058A6, # B5 F4 - decrease background color addition by 4 (white) + 0x1058AB, # B5 F4 - decrease background color addition by 4 (white) + 0x1058B0, # B5 F4 - decrease background color addition by 4 (white) + ], + "X-Zone": [ + 0x105A5D, # B0 FF - set background color addition to 31 (white) + 0x105A6A, # B5 F2 - decrease background color addition by 2 (white) + 0x105A79, # B5 F2 - decrease background color addition by 2 (white) + ], + "Dispel": [ + 0x105DC2, # B0 FF - set background color addition to 31 (white) + 0x105DC9, # B5 F1 - decrease background color addition by 1 (white) + 0x105DD2, # B5 F1 - decrease background color addition by 1 (white) + 0x105DDB, # B5 F1 - decrease background color addition by 1 (white) + 0x105DE4, # B5 F1 - decrease background color addition by 1 (white) + 0x105DED, # B5 F1 - decrease background color addition by 1 (white) + ], + "Muddle": [ # Also L.3 Muddle, Confusion + 0x1060EA, # B0 FF - set background color addition to 31 (white) + 0x1060EE, # B5 F1 - decrease background color addition by 1 (white) + ], + "Shock": [ + 0x1068BE, # B0 FF - set background color addition to 31 (white) + 0x1068D0, # B5 F1 - decrease background color addition by 1 (white) + ], + "Bum Rush": [ + 0x106C3E, # B0 E0 - set background color addition to 0 (white) + 0x106C47, # B0 E0 - set background color addition to 0 (white) + 0x106C53, # B0 E0 - set background color addition to 0 (white) + 0x106C7E, # B0 FF - set background color addition to 31 (white) + 0x106C87, # B0 E0 - set background color addition to 0 (white) + 0x106C95, # B0 FF - set background color addition to 31 (white) + 0x106C9E, # B0 E0 - set background color addition to 0 (white) + ], + "Stunner": [ + 0x1071BA, # B0 20 - set background color addition to 0 (blue) + 0x1071C1, # B5 24 - increase background color addition by 4 (blue) + 0x1071CA, # B5 24 - increase background color addition by 4 (blue) + 0x1071D5, # B5 24 - increase background color addition by 4 (blue) + 0x1071DE, # B5 24 - increase background color addition by 4 (blue) + 0x1071E9, # B5 24 - increase background color addition by 4 (blue) + 0x1071F2, # B5 24 - increase background color addition by 4 (blue) + 0x1071FD, # B5 24 - increase background color addition by 4 (blue) + 0x107206, # B5 24 - increase background color addition by 4 (blue) + 0x107211, # B5 24 - increase background color addition by 4 (blue) + 0x10721A, # B5 24 - increase background color addition by 4 (blue) + 0x10725A, # B5 32 - decrease background color addition by 2 (blue) + ], + "Quadra Slam": [ # Also Quadra Slice + 0x1073DC, # B0 FF - set background color addition to 31 (white) + 0x1073EE, # B5 F2 - decrease background color addition by 2 (white) + 0x1073F3, # B5 F2 - decrease background color addition by 2 (white) + 0x107402, # B0 5F - set background color addition to 31 (green) + 0x107424, # B5 54 - decrease background color addition by 4 (green) + 0x107429, # B5 54 - decrease background color addition by 4 (green) + 0x107436, # B0 3F - set background color addition to 31 (blue) + 0x107458, # B5 34 - decrease background color addition by 4 (blue) + 0x10745D, # B5 34 - decrease background color addition by 4 (blue) + 0x107490, # B0 9F - set background color addition to 31 (red) + 0x1074B2, # B5 94 - decrease background color addition by 4 (red) + 0x1074B7, # B5 94 - decrease background color addition by 4 (red) + ], + "Slash": [ + 0x1074F4, # B0 FF - set background color addition to 31 (white) + 0x1074FD, # B5 F2 - decrease background color addition by 2 (white) + 0x107507, # B5 F2 - decrease background color addition by 2 (white) + ], + "Flash": [ + 0x107850, # B0 FF - set background color addition to 31 (white) + 0x10785C, # B5 F1 - decrease background color addition by 1 (white) + ] +} + diff --git a/data/character.py b/data/character.py index db012726..022cfef7 100644 --- a/data/character.py +++ b/data/character.py @@ -85,7 +85,7 @@ def init_run_success(self, value): if value < self.MIN_RUN_SUCCESS or value > self.MAX_RUN_SUCCESS: raise ValueError(f"Character.init_run_success setter: invalid value {value}") - self._init_run_success = value - self.MAX_RUN_SUCCESS + self._init_run_success = self.MAX_RUN_SUCCESS - value # initial level of characters is 3 # when new character is recruited, their level is set to the average of all other recruited characters + init_level_factor diff --git a/data/character_palettes.py b/data/character_palettes.py index 7beb0180..1ced874d 100644 --- a/data/character_palettes.py +++ b/data/character_palettes.py @@ -77,6 +77,10 @@ def mod_palette_colors(self): with open(palette_file, "rb") as pfile: palette_data = list(pfile.read()) + expected_size = len(self.field_palettes[palette_index].data) + if len(palette_data) != expected_size: + raise ValueError(f"palette '{palette_file}' is {len(palette_data)} bytes, expected {expected_size}") + self.field_palettes[palette_index].data = palette_data self.battle_palettes[palette_index].data = palette_data @@ -104,6 +108,9 @@ def mod_portrait_palettes(self): with open(portrait_palette_file, "rb") as pfile: palette_data = list(pfile.read()) + expected_size = len(self.portrait_palettes[character].data) + if len(palette_data) != expected_size: + raise ValueError(f"portrait palette '{portrait_palette_file}' is {len(palette_data)} bytes, expected {expected_size}") self.portrait_palettes[character].data = palette_data def mod(self): diff --git a/data/character_sprites.py b/data/character_sprites.py index aef33c5e..c9a79358 100644 --- a/data/character_sprites.py +++ b/data/character_sprites.py @@ -79,6 +79,9 @@ def mod_character_portraits(self): with open(portrait_sprite_file, "rb") as pfile: portrait_data = list(pfile.read()) + expected_size = len(self.portrait_sprites[character].data) + if len(portrait_data) != expected_size: + raise ValueError(f"portrait '{portrait_sprite_file}' is {len(portrait_data)} bytes, expected {expected_size}") self.portrait_sprites[character].data = portrait_data def mod(self): diff --git a/data/control.py b/data/control.py index 95e971f8..f5d945f7 100644 --- a/data/control.py +++ b/data/control.py @@ -1,20 +1,20 @@ -class Control(): - def __init__(self, id, attack_data): - self.id = id - - self.attack_data_array = attack_data - - def attack_data(self): - from data.controls import Controls - data = [0x00] * Controls.ATTACKS_DATA_SIZE - - data = self.attack_data_array - - return data - - def print(self): - attack_str = "" - for attack in self.attack_data: - attack_str += f"{attack} " - - print(f"{self.id} {attack_str}") +class Control(): + def __init__(self, id, attack_data): + self.id = id + + self.attack_data_array = attack_data + + def attack_data(self): + from data.controls import Controls + data = [0x00] * Controls.ATTACKS_DATA_SIZE + + data = self.attack_data_array + + return data + + def print(self): + attack_str = "" + for attack in self.attack_data: + attack_str += f"{attack} " + + print(f"{self.id} {attack_str}") diff --git a/data/controls.py b/data/controls.py index 9012eac8..6ddfbd39 100644 --- a/data/controls.py +++ b/data/controls.py @@ -1,154 +1,154 @@ -from data.control import Control -from data.structures import DataArray -from memory.space import Reserve, Allocate, Bank, Write -import instruction.asm as asm - -class Controls(): - ATTACKS_DATA_START = 0xf3d00 - ATTACKS_DATA_END = 0xf42ff - ATTACKS_DATA_SIZE = 4 - ATTACKS_DATA_TOTAL_BYTES = (ATTACKS_DATA_END - ATTACKS_DATA_START) + 1 - - def __init__(self, rom, args, enemies, rages): - self.rom = rom - self.args = args - self.enemies = enemies - self.rages = rages - - # Copy the vanilla table to a new location, so that any modifications do not affect Coliseum/Muddle behavior - self.new_attack_data_space = Allocate(Bank.F0, self.ATTACKS_DATA_TOTAL_BYTES, "new Controls table") - self.new_attack_data_space.copy_from(self.ATTACKS_DATA_START, self.ATTACKS_DATA_END) - - self.attack_data = DataArray(self.rom, self.new_attack_data_space.start_address, self.new_attack_data_space.end_address, self.ATTACKS_DATA_SIZE) - - self.controls = [] - for control_index in range(len(self.attack_data)): - control = Control(control_index, self.attack_data[control_index]) - self.controls.append(control) - - def split_control_table(self): - # Update the vanilla lookup of the table for Control commands - # Default: LDA $CF3D00,X - space = Reserve(0x23758, 0x2375B, "get Control command table") - space.write( - asm.LDA(self.new_attack_data_space.start_address_snes, asm.LNG_X) - ) - - def ignore_randomize_target(self): - # Ignoring Randomize Target bit when Control is used, to ensure that those commands respect the selected targetting - # This is a bug-fix for a vanilla bug, in which Controlled Dance abilities (ex: Sandstorm) swap targetting. - src = [ - asm.LDA(0x3A7A, asm.ABS), # load the command - asm.CMP(0x0E, asm.IMM8), # is it Control? - asm.BEQ("exit"), # if so, skip over displaced code - # displaced code from C2/276A - C2/2771 to read the "Randomize target bit" and set the equivalent in $BA - asm.LDA(0x01, asm.S), - asm.AND(0x10, asm.IMM8), - asm.ASL(), - asm.ASL(), - asm.TSB(0xBA, asm.DIR), - "exit", - asm.RTS(), - ] - space = Write(Bank.C2, src, "Control: ignore Randomize Target bit") - ignore_randomize_target_addr = space.start_address - - # Call our new subroutine - space = Reserve(0x2276A, 0x22771, "control: call ignore randomize target bit subroutine", asm.NOP()) - space.write( - asm.JSR(ignore_randomize_target_addr, asm.ABS) - ) - - def enable_control_casters_stats(self): - src = [ - # X = entity using command (in Control case, this is the monster being controlled) - asm.LDA(0x32B9,asm.ABS_X), # who's Controlling this entity? - asm.CMP(0xFF, asm.IMM8), - asm.BEQ("exit"), # branch if nobody controls them - asm.TAX(), # if there's a valid Controller, use their stats (vigor/magic/level) - asm.LDA(0x11A2, asm.ABS), #Spell Properties - asm.LSR(), #Check if Physical/Magical - asm.LDA(0x3B41, asm.ABS_X), #Controller's Mag.Pwr - asm.BCC("magical"), #Branch if not physical damage - asm.LDA(0x3B2C, asm.ABS_X), #Controller's Vigor * 2 - "magical", - asm.STA(0x11AE, asm.ABS), #Set Controller's Magic or Vigor - "exit", - asm.LDA(0x3B18, asm.ABS_X), # displaced code: get Level - asm.RTS(), - ] - space = Write(Bank.C2, src, "Controller Caster Stats") - use_controller_stats_addr = space.start_address - - # Call our new subroutine - space = Reserve(0x22c28, 0x22c2A, "jump to new routine") - space.write( - asm.JSR(use_controller_stats_addr, asm.ABS) - ) - - def enable_control_chances_always(self): - # Always Control if the target is valid - # NOPing the JSR and BCS that can prevent Control from working - space = Reserve(0x023ae8, 0x023aec, "control always", asm.NOP()) - - def enable_control_improved_abilities(self): - from data.spell_names import name_id - # Ensure that Rage & Special are available (if there are open Controls) - for control in self.controls: - # Search for blanks, rages, and specials - index_of_blank = self.ATTACKS_DATA_SIZE # default to end - control_has_rage = False - control_has_special = False - for attack_index, attack in enumerate(control.attack_data()): - # Look for the first blank entry - if index_of_blank == self.ATTACKS_DATA_SIZE and attack == name_id["??????????"]: - index_of_blank = attack_index - # Look for a rage - if control.id < self.rages.RAGE_COUNT: # Enemy has a rage - if attack == self.rages.rages[control.id].attack2 and not control_has_rage: - control_has_rage = True - else: - control_has_rage = True # no rages to have - # Look for a special - if attack == name_id["Special"] and not control_has_special: - control_has_special = True - - # If we found that it doesn't have a rage and there's room, add the rage - if not control_has_rage and index_of_blank < self.ATTACKS_DATA_SIZE: - control.attack_data_array[index_of_blank] = self.rages.rages[control.id].attack2 - # Avoid duplicate Specials if Rage == Special - if control.attack_data_array[index_of_blank] == name_id["Special"]: - control_has_special = True - index_of_blank = index_of_blank + 1 - - # If we found that it doesn't have a Special and there's room, add the Special - if not control_has_special and index_of_blank < self.ATTACKS_DATA_SIZE: - control.attack_data_array[index_of_blank] = name_id["Special"] - index_of_blank = index_of_blank + 1 - - def mod(self): - - self.ignore_randomize_target() - - if self.args.sketch_control_improved_stats: - self.enable_control_chances_always() - self.enable_control_casters_stats() - if self.args.sketch_control_improved_abilities: - self.split_control_table() - self.enable_control_improved_abilities() - - def write(self): - if self.args.spoiler_log: - self.log() - - for control_index, control in enumerate(self.controls): - self.attack_data[control_index] = control.attack_data() - - self.attack_data.write() - - def log(self): - pass - - def print(self): - for control in self.controls: - control.print() +from data.control import Control +from data.structures import DataArray +from memory.space import Reserve, Allocate, Bank, Write +import instruction.asm as asm + +class Controls(): + ATTACKS_DATA_START = 0xf3d00 + ATTACKS_DATA_END = 0xf42ff + ATTACKS_DATA_SIZE = 4 + ATTACKS_DATA_TOTAL_BYTES = (ATTACKS_DATA_END - ATTACKS_DATA_START) + 1 + + def __init__(self, rom, args, enemies, rages): + self.rom = rom + self.args = args + self.enemies = enemies + self.rages = rages + + # Copy the vanilla table to a new location, so that any modifications do not affect Coliseum/Muddle behavior + self.new_attack_data_space = Allocate(Bank.F0, self.ATTACKS_DATA_TOTAL_BYTES, "new Controls table") + self.new_attack_data_space.copy_from(self.ATTACKS_DATA_START, self.ATTACKS_DATA_END) + + self.attack_data = DataArray(self.rom, self.new_attack_data_space.start_address, self.new_attack_data_space.end_address, self.ATTACKS_DATA_SIZE) + + self.controls = [] + for control_index in range(len(self.attack_data)): + control = Control(control_index, self.attack_data[control_index]) + self.controls.append(control) + + def split_control_table(self): + # Update the vanilla lookup of the table for Control commands + # Default: LDA $CF3D00,X + space = Reserve(0x23758, 0x2375B, "get Control command table") + space.write( + asm.LDA(self.new_attack_data_space.start_address_snes, asm.LNG_X) + ) + + def ignore_randomize_target(self): + # Ignoring Randomize Target bit when Control is used, to ensure that those commands respect the selected targetting + # This is a bug-fix for a vanilla bug, in which Controlled Dance abilities (ex: Sandstorm) swap targetting. + src = [ + asm.LDA(0x3A7A, asm.ABS), # load the command + asm.CMP(0x0E, asm.IMM8), # is it Control? + asm.BEQ("exit"), # if so, skip over displaced code + # displaced code from C2/276A - C2/2771 to read the "Randomize target bit" and set the equivalent in $BA + asm.LDA(0x01, asm.S), + asm.AND(0x10, asm.IMM8), + asm.ASL(), + asm.ASL(), + asm.TSB(0xBA, asm.DIR), + "exit", + asm.RTS(), + ] + space = Write(Bank.C2, src, "Control: ignore Randomize Target bit") + ignore_randomize_target_addr = space.start_address + + # Call our new subroutine + space = Reserve(0x2276A, 0x22771, "control: call ignore randomize target bit subroutine", asm.NOP()) + space.write( + asm.JSR(ignore_randomize_target_addr, asm.ABS) + ) + + def enable_control_casters_stats(self): + src = [ + # X = entity using command (in Control case, this is the monster being controlled) + asm.LDA(0x32B9,asm.ABS_X), # who's Controlling this entity? + asm.CMP(0xFF, asm.IMM8), + asm.BEQ("exit"), # branch if nobody controls them + asm.TAX(), # if there's a valid Controller, use their stats (vigor/magic/level) + asm.LDA(0x11A2, asm.ABS), #Spell Properties + asm.LSR(), #Check if Physical/Magical + asm.LDA(0x3B41, asm.ABS_X), #Controller's Mag.Pwr + asm.BCC("magical"), #Branch if not physical damage + asm.LDA(0x3B2C, asm.ABS_X), #Controller's Vigor * 2 + "magical", + asm.STA(0x11AE, asm.ABS), #Set Controller's Magic or Vigor + "exit", + asm.LDA(0x3B18, asm.ABS_X), # displaced code: get Level + asm.RTS(), + ] + space = Write(Bank.C2, src, "Controller Caster Stats") + use_controller_stats_addr = space.start_address + + # Call our new subroutine + space = Reserve(0x22c28, 0x22c2A, "jump to new routine") + space.write( + asm.JSR(use_controller_stats_addr, asm.ABS) + ) + + def enable_control_chances_always(self): + # Always Control if the target is valid + # NOPing the JSR and BCS that can prevent Control from working + space = Reserve(0x023ae8, 0x023aec, "control always", asm.NOP()) + + def enable_control_improved_abilities(self): + from data.spell_names import name_id + # Ensure that Rage & Special are available (if there are open Controls) + for control in self.controls: + # Search for blanks, rages, and specials + index_of_blank = self.ATTACKS_DATA_SIZE # default to end + control_has_rage = False + control_has_special = False + for attack_index, attack in enumerate(control.attack_data()): + # Look for the first blank entry + if index_of_blank == self.ATTACKS_DATA_SIZE and attack == name_id["??????????"]: + index_of_blank = attack_index + # Look for a rage + if control.id < self.rages.RAGE_COUNT: # Enemy has a rage + if attack == self.rages.rages[control.id].attack2 and not control_has_rage: + control_has_rage = True + else: + control_has_rage = True # no rages to have + # Look for a special + if attack == name_id["Special"] and not control_has_special: + control_has_special = True + + # If we found that it doesn't have a rage and there's room, add the rage + if not control_has_rage and index_of_blank < self.ATTACKS_DATA_SIZE: + control.attack_data_array[index_of_blank] = self.rages.rages[control.id].attack2 + # Avoid duplicate Specials if Rage == Special + if control.attack_data_array[index_of_blank] == name_id["Special"]: + control_has_special = True + index_of_blank = index_of_blank + 1 + + # If we found that it doesn't have a Special and there's room, add the Special + if not control_has_special and index_of_blank < self.ATTACKS_DATA_SIZE: + control.attack_data_array[index_of_blank] = name_id["Special"] + index_of_blank = index_of_blank + 1 + + def mod(self): + + self.ignore_randomize_target() + + if self.args.sketch_control_improved_stats: + self.enable_control_chances_always() + self.enable_control_casters_stats() + if self.args.sketch_control_improved_abilities: + self.split_control_table() + self.enable_control_improved_abilities() + + def write(self): + if self.args.spoiler_log: + self.log() + + for control_index, control in enumerate(self.controls): + self.attack_data[control_index] = control.attack_data() + + self.attack_data.write() + + def log(self): + pass + + def print(self): + for control in self.controls: + control.print() diff --git a/data/enemy_script_custom_commands.py b/data/enemy_script_custom_commands.py index ae4e0317..5b9103f3 100644 --- a/data/enemy_script_custom_commands.py +++ b/data/enemy_script_custom_commands.py @@ -1,5 +1,6 @@ from memory.space import Bank, START_ADDRESS_SNES, Reserve, Allocate, Write import instruction.asm as asm +from constants.battle_addresses import ENEMY_LEVEL # 0xf0 custom argument values (e.g. FIRE1 = randomly choose from fire category with tier +1) FIRE0, FIRE1, FIRE2, ICE0, ICE1, ICE2, BOLT0, BOLT1, BOLT2, EARTH0, EARTH1, EARTH2, WIND0, WIND1, WIND2, WATER0, WATER1, WATER2, POISON0, POISON1, POISON2, PEARL0, PEARL1, PEARL2, NON_ELEMENTAL0, NON_ELEMENTAL1, NON_ELEMENTAL2 = range(0x36, 0x51) @@ -190,7 +191,7 @@ def random_tiered_ability_mod(self, space): asm.PHA(), # push index of tier 0 for category # add the enemy tier to the above category tier offset - asm.LDA(0x3b18, asm.ABS_Y), # a = enemy's level + asm.LDA(ENEMY_LEVEL, asm.ABS_Y), # a = enemy's level asm.CMP(int(ENEMY_LEVELS_PER_TIER * MAX_ENEMY_LEVEL_TIER), asm.IMM8), asm.BLT("TIER Received the Magicite “Bismark.”') dialogs.set_text(self.receive_dialogs[self.ODIN], ' Received the Magicite “Odin.”') dialogs.set_text(self.receive_dialogs[self.RAIDEN], ' Received the Magicite “Raiden.”') - #dialogs.set_text(self.receive_dialogs[self.RAIDEN], ' The Magicite "Odin" gains a level… and becomes the Magicite "Raiden!"') dialogs.set_text(self.receive_dialogs[self.RAGNAROK], ' Received the Magicite “Ragnarok.”') dialogs.set_text(self.receive_dialogs[self.CARBUNKL], ' Received the Magicite “Carbunkl.”') dialogs.set_text(self.receive_dialogs[self.PHANTOM], ' Received the Magicite “Phantom.”') @@ -101,10 +100,20 @@ def shuffle_spells(self): random.shuffle(spell_counts) + # if every esper with open slots already knows the next spell, the loop + # below can no longer make progress; fail loudly instead of hanging. + # the guards consume no rng so seed output is unchanged + stalled_picks = 0 + MAX_STALLED_PICKS = 10000 + while len(spells) > 0: + if not esper_indices or stalled_picks > MAX_STALLED_PICKS: + raise RuntimeError(f"shuffle_spells: cannot place {len(spells)} remaining spells, " + f"{len(esper_indices)} espers have open slots") esper_index = random.choice(esper_indices) esper = self.espers[esper_index] if not esper.has_spell(spells[-1].id): + stalled_picks = 0 spell = spells.pop() if self.args.esper_spells_shuffle_random_rates: esper.add_spell(spell.id, random.choice(Esper.LEARN_RATES)) @@ -112,6 +121,8 @@ def shuffle_spells(self): esper.add_spell(spell.id, spell.rate) if esper.spell_count == spell_counts[esper_index]: esper_indices.remove(esper_index) + else: + stalled_picks += 1 def randomize_spells(self): for esper in self.espers: diff --git a/data/shops.py b/data/shops.py index 70f1c4c3..c96e0082 100644 --- a/data/shops.py +++ b/data/shops.py @@ -69,14 +69,26 @@ def shuffle(self): random.shuffle(item_counts) + # if every shop with open slots already stocks the next item, the loop + # below can no longer make progress; fail loudly instead of hanging. + # the guards consume no rng so seed output is unchanged + stalled_picks = 0 + MAX_STALLED_PICKS = 10000 + while len(items) > 0: + if not shop_indices or stalled_picks > MAX_STALLED_PICKS: + raise RuntimeError(f"shops shuffle: cannot place {len(items)} remaining items, " + f"{len(shop_indices)} shops have open slots") shop_index = random.choice(shop_indices) shop = type_shops[shop_type][shop_index] if not shop.contains(items[-1]): + stalled_picks = 0 item = items.pop() shop.append(item) if shop.item_count == item_counts[shop_index]: shop_indices.remove(shop_index) + else: + stalled_picks += 1 def random_tiered(self): def get_item(item_type, exclude = None): diff --git a/data/sketch.py b/data/sketch.py index 0f90018c..1e82499e 100644 --- a/data/sketch.py +++ b/data/sketch.py @@ -1,18 +1,18 @@ -class Sketch(): - def __init__(self, id, attack_data): - self.id = id - - self.rare = attack_data[0] - self.common = attack_data[1] - - def attack_data(self): - from data.sketches import Sketches - data = [0x00] * Sketches.ATTACKS_DATA_SIZE - - data[0] = self.rare - data[1] = self.common - - return data - - def print(self): - print(f"{self.id} {self.rare} {self.common}") +class Sketch(): + def __init__(self, id, attack_data): + self.id = id + + self.rare = attack_data[0] + self.common = attack_data[1] + + def attack_data(self): + from data.sketches import Sketches + data = [0x00] * Sketches.ATTACKS_DATA_SIZE + + data[0] = self.rare + data[1] = self.common + + return data + + def print(self): + print(f"{self.id} {self.rare} {self.common}") diff --git a/data/sketch_custom_commands.py b/data/sketch_custom_commands.py index ec2762ef..08d067de 100644 --- a/data/sketch_custom_commands.py +++ b/data/sketch_custom_commands.py @@ -1,54 +1,54 @@ -from data.bosses import name_enemy -from data.spell_names import name_id - -# This dictionary contains sketch command overrides for specific enemies -# Each array is in the order of [Rare (25%), Common (75%)] -custom_commands = { - name_enemy["Vargas"] : [name_id["Gale Cut"] , name_id["Special"]], - name_enemy["TunnelArmr"] : [name_id["Tek Laser"] , name_id["Special"]], - name_enemy["GhostTrain"] : [name_id["Scar Beam"] , name_id["Special"]], - name_enemy["Dadaluma"] : [name_id["Shock Wave"], name_id["Special"]], - name_enemy["Shiva"] : [name_id["Rflect"] , name_id["Special"]], - name_enemy["Number 024"] : [name_id["Cure 2"] , name_id["Scan"]], - name_enemy["Number 128"] : [name_id["Net"] , name_id["Special"]], - name_enemy["Inferno"] : [name_id["Bolt 3"] , name_id["TekBarrier"]], - name_enemy["Left Crane"] : [name_id["TekBarrier"], name_id["Special"]], - name_enemy["Right Crane"] : [name_id["TekBarrier"], name_id["Special"]], - name_enemy["AtmaWeapon"] : [name_id["Bio"] , name_id["Special"]], - name_enemy["KatanaSoul"] : [name_id["Special"] , name_id["Shock Wave"]], - name_enemy["Red Dragon"] : [name_id["Flare"] , name_id["L? Pearl"]], # L? Pearl is a vanilla sketch - name_enemy["Blue Drgn"] : [name_id["Slow"] , name_id["Ice 3"]], # Ice 3 is a vanilla sketch - name_enemy["Skull Drgn"] : [name_id["Elf Fire"] , name_id["Rasp"]], # Rasp is a vanilla sketch - name_enemy["Gold Drgn"] : [name_id["Ice 3"] , name_id["Rflect"]], # Ice 3 is a vanilla sketch - name_enemy["Storm Drgn"] : [name_id["Aero"] , name_id["Pearl Wind"]], # vanilla sketches; just reversed. Useful for Lore. - name_enemy["Whelk"] : [name_id["Mega Volt"] , name_id["Mega Volt"]], - name_enemy["Presenter"] : [name_id["Magnitude8"], name_id["Blow Fish"]], - name_enemy["Air Force"] : [name_id["WaveCannon"], name_id["Tek Laser"]], - name_enemy["Laser Gun"] : [name_id["Diffuser"] , name_id["Tek Laser"]], - name_enemy["FlameEater"] : [name_id["Flare"] , name_id["Rflect"]], - name_enemy["Nerapa"] : [name_id["Condemned"] , name_id["Condemned"]], - name_enemy["SrBehemoth"] : [name_id["Meteo"] , name_id["Pearl"]], - name_enemy["Dullahan"] : [name_id["Pearl"] , name_id["Cure 2"]], - name_enemy["Doom Gaze"] : [name_id["Aero"] , name_id["Aero"]], - name_enemy["Curley"] : [name_id["Fire 3"] , name_id["Pearl Wind"]], - name_enemy["Larry"] : [name_id["Ice 3"] , name_id["Rflect"]], - name_enemy["Moe"] : [name_id["Bolt 3"] , name_id["Shell"]], - name_enemy["Wrexsoul"] : [name_id["Bolt 3"] , name_id["Bolt 3"]], - name_enemy["Hidon"] : [name_id["GrandTrain"], name_id["Poison"]], # Poison will be more common, and heal. May be worth it to learn GrandTrain - name_enemy["Doom"] : [name_id["Special"] , name_id["ForceField"]], - name_enemy["Goddess"] : [name_id["Quasar"] , name_id["Bolt 3"]], # Bolt 3 will be more common and heal. May be worth it to learn Quasar - name_enemy["Poltrgeist"] : [name_id["Meteo"] , name_id["Shrapnel"]], - name_enemy["Ultros 1"] : [name_id["Special"] , name_id["Tentacle"]], - name_enemy["Ultros 2"] : [name_id["Special"] , name_id["Tentacle"]], - name_enemy["Ultros 4"] : [name_id["Special"] , name_id["Tentacle"]], - name_enemy["Striker"] : [name_id["Shrapnel"] , name_id["Special"]], - name_enemy["Tritoch"] : [name_id["Rasp"] , name_id["Rasp"]], - name_enemy["Chadarnook (Demon)"] : [name_id["Flash Rain"], name_id["Flash Rain"]], - name_enemy["Kefka (Narshe)"] : [name_id["Ice 2"] , name_id["Ice 2"]], - name_enemy["Rizopas"] : [name_id["Mega Volt"] , name_id["Special"]], - name_enemy["MagiMaster"] : [name_id["Fire 3"] , name_id["Ice 3"]], # Powerful abilities, but may heal - name_enemy["Naughty"] : [name_id["Cold Dust"] , name_id["Mute"]], - name_enemy["Phunbaba 3"] : [name_id["Special"] , name_id["Blow Fish"]], - name_enemy["Phunbaba 4"] : [name_id["Special"] , name_id["Blow Fish"]], - name_enemy["Atma"] : [name_id["Flare Star"], name_id["S. Cross"]], +from data.bosses import name_enemy +from data.spell_names import name_id + +# This dictionary contains sketch command overrides for specific enemies +# Each array is in the order of [Rare (25%), Common (75%)] +custom_commands = { + name_enemy["Vargas"] : [name_id["Gale Cut"] , name_id["Special"]], + name_enemy["TunnelArmr"] : [name_id["Tek Laser"] , name_id["Special"]], + name_enemy["GhostTrain"] : [name_id["Scar Beam"] , name_id["Special"]], + name_enemy["Dadaluma"] : [name_id["Shock Wave"], name_id["Special"]], + name_enemy["Shiva"] : [name_id["Rflect"] , name_id["Special"]], + name_enemy["Number 024"] : [name_id["Cure 2"] , name_id["Scan"]], + name_enemy["Number 128"] : [name_id["Net"] , name_id["Special"]], + name_enemy["Inferno"] : [name_id["Bolt 3"] , name_id["TekBarrier"]], + name_enemy["Left Crane"] : [name_id["TekBarrier"], name_id["Special"]], + name_enemy["Right Crane"] : [name_id["TekBarrier"], name_id["Special"]], + name_enemy["AtmaWeapon"] : [name_id["Bio"] , name_id["Special"]], + name_enemy["KatanaSoul"] : [name_id["Special"] , name_id["Shock Wave"]], + name_enemy["Red Dragon"] : [name_id["Flare"] , name_id["L? Pearl"]], # L? Pearl is a vanilla sketch + name_enemy["Blue Drgn"] : [name_id["Slow"] , name_id["Ice 3"]], # Ice 3 is a vanilla sketch + name_enemy["Skull Drgn"] : [name_id["Elf Fire"] , name_id["Rasp"]], # Rasp is a vanilla sketch + name_enemy["Gold Drgn"] : [name_id["Ice 3"] , name_id["Rflect"]], # Ice 3 is a vanilla sketch + name_enemy["Storm Drgn"] : [name_id["Aero"] , name_id["Pearl Wind"]], # vanilla sketches; just reversed. Useful for Lore. + name_enemy["Whelk"] : [name_id["Mega Volt"] , name_id["Mega Volt"]], + name_enemy["Presenter"] : [name_id["Magnitude8"], name_id["Blow Fish"]], + name_enemy["Air Force"] : [name_id["WaveCannon"], name_id["Tek Laser"]], + name_enemy["Laser Gun"] : [name_id["Diffuser"] , name_id["Tek Laser"]], + name_enemy["FlameEater"] : [name_id["Flare"] , name_id["Rflect"]], + name_enemy["Nerapa"] : [name_id["Condemned"] , name_id["Condemned"]], + name_enemy["SrBehemoth"] : [name_id["Meteo"] , name_id["Pearl"]], + name_enemy["Dullahan"] : [name_id["Pearl"] , name_id["Cure 2"]], + name_enemy["Doom Gaze"] : [name_id["Aero"] , name_id["Aero"]], + name_enemy["Curley"] : [name_id["Fire 3"] , name_id["Pearl Wind"]], + name_enemy["Larry"] : [name_id["Ice 3"] , name_id["Rflect"]], + name_enemy["Moe"] : [name_id["Bolt 3"] , name_id["Shell"]], + name_enemy["Wrexsoul"] : [name_id["Bolt 3"] , name_id["Bolt 3"]], + name_enemy["Hidon"] : [name_id["GrandTrain"], name_id["Poison"]], # Poison will be more common, and heal. May be worth it to learn GrandTrain + name_enemy["Doom"] : [name_id["Special"] , name_id["ForceField"]], + name_enemy["Goddess"] : [name_id["Quasar"] , name_id["Bolt 3"]], # Bolt 3 will be more common and heal. May be worth it to learn Quasar + name_enemy["Poltrgeist"] : [name_id["Meteo"] , name_id["Shrapnel"]], + name_enemy["Ultros 1"] : [name_id["Special"] , name_id["Tentacle"]], + name_enemy["Ultros 2"] : [name_id["Special"] , name_id["Tentacle"]], + name_enemy["Ultros 4"] : [name_id["Special"] , name_id["Tentacle"]], + name_enemy["Striker"] : [name_id["Shrapnel"] , name_id["Special"]], + name_enemy["Tritoch"] : [name_id["Rasp"] , name_id["Rasp"]], + name_enemy["Chadarnook (Demon)"] : [name_id["Flash Rain"], name_id["Flash Rain"]], + name_enemy["Kefka (Narshe)"] : [name_id["Ice 2"] , name_id["Ice 2"]], + name_enemy["Rizopas"] : [name_id["Mega Volt"] , name_id["Special"]], + name_enemy["MagiMaster"] : [name_id["Fire 3"] , name_id["Ice 3"]], # Powerful abilities, but may heal + name_enemy["Naughty"] : [name_id["Cold Dust"] , name_id["Mute"]], + name_enemy["Phunbaba 3"] : [name_id["Special"] , name_id["Blow Fish"]], + name_enemy["Phunbaba 4"] : [name_id["Special"] , name_id["Blow Fish"]], + name_enemy["Atma"] : [name_id["Flare Star"], name_id["S. Cross"]], } \ No newline at end of file diff --git a/data/sketches.py b/data/sketches.py index 233956f2..ccfc3ab3 100644 --- a/data/sketches.py +++ b/data/sketches.py @@ -1,102 +1,102 @@ -from data.sketch import Sketch -from data.structures import DataArray -from memory.space import Reserve, Bank, Write -import instruction.asm as asm - -class Sketches(): - ATTACKS_DATA_START = 0xf4300 - ATTACKS_DATA_END = 0xf45ff - ATTACKS_DATA_SIZE = 2 - - def __init__(self, rom, args, enemies, rages): - self.rom = rom - self.args = args - self.enemies = enemies - self.rages = rages - - self.attack_data = DataArray(self.rom, self.ATTACKS_DATA_START, self.ATTACKS_DATA_END, self.ATTACKS_DATA_SIZE) - - self.sketches = [] - for sketch_index in range(len(self.attack_data)): - sketch = Sketch(sketch_index, self.attack_data[sketch_index]) - self.sketches.append(sketch) - - def enable_sketch_chances_always(self): - # Always Sketch if the target is valid - # NOPing the JSR and BCS that can prevent Sketch from working - space = Reserve(0x023b3d, 0x023b41, "sketch always", asm.NOP()) - - def enable_sketch_casters_stats(self): - # Based on https://www.ff6hacking.com/forums/thread-3478.html - - # New subroutine. Note that most of this logic is the same as vanilla logic at C2/2954. - src = [ - # A = Character using sketch (from $3417) - asm.BMI("exit"), #Branch if no Sketcher - asm.TAX(), #if there's a valid Sketcher, use their Level for attack by making X = character offset - asm.LDA(0x11A2, asm.ABS), #Spell Properties - asm.LSR(), #Check if Physical/Magical - asm.LDA(0x3B41, asm.ABS_X), #Sketcher's Mag.Pwr - asm.BCC("magical"), #Branch if not physical damage - asm.LDA(0x3B2C, asm.ABS_X), #Sketcher's Vigor * 2 - "magical", - asm.STA(0x11AE, asm.ABS), #Set Sketcher's Magic or Vigor - "exit", - asm.RTS(), - ] - space = Write(Bank.C2, src, "Sketch Caster Stats") - use_sketcher_stats_addr = space.start_address - - # Call our new subroutine - space = Reserve(0x22c25, 0x22c27, "jump to new routine") - space.write( - asm.JSR(use_sketcher_stats_addr, asm.ABS) - ) - - def enable_sketch_improved_abilities(self): - from data.spell_names import name_id - from data.sketch_custom_commands import custom_commands - - for sketch in self.sketches: - # if either is Battle, replace with opposite - if sketch.rare == name_id["Battle"]: - sketch.rare = sketch.common - if sketch.common == name_id["Battle"]: - sketch.common = sketch.rare - # If both are Battle, replace both with Rage (if it exists) - if sketch.rare == name_id["Battle"] and sketch.common == name_id["Battle"]: - if sketch.id < self.rages.RAGE_COUNT: - rage = self.rages.rages[sketch.id] - sketch.rare = rage.attack2 - sketch.common = rage.attack2 - # If both are identical, replace rare with Rage (if it exists) - if sketch.rare == sketch.common: - if sketch.id < self.rages.RAGE_COUNT: - sketch.rare = self.rages.rages[sketch.id].attack2 - # Override with custom commands - if sketch.id in custom_commands: - sketch.rare = custom_commands[sketch.id][0] - sketch.common = custom_commands[sketch.id][1] - - def mod(self): - if self.args.sketch_control_improved_stats: - self.enable_sketch_chances_always() - self.enable_sketch_casters_stats() - if self.args.sketch_control_improved_abilities: - self.enable_sketch_improved_abilities() - - def write(self): - if self.args.spoiler_log: - self.log() - - for sketch_index, sketch in enumerate(self.sketches): - self.attack_data[sketch_index] = sketch.attack_data() - - self.attack_data.write() - - def log(self): - pass - - def print(self): - for sketch in self.sketches: - sketch.print() +from data.sketch import Sketch +from data.structures import DataArray +from memory.space import Reserve, Bank, Write +import instruction.asm as asm + +class Sketches(): + ATTACKS_DATA_START = 0xf4300 + ATTACKS_DATA_END = 0xf45ff + ATTACKS_DATA_SIZE = 2 + + def __init__(self, rom, args, enemies, rages): + self.rom = rom + self.args = args + self.enemies = enemies + self.rages = rages + + self.attack_data = DataArray(self.rom, self.ATTACKS_DATA_START, self.ATTACKS_DATA_END, self.ATTACKS_DATA_SIZE) + + self.sketches = [] + for sketch_index in range(len(self.attack_data)): + sketch = Sketch(sketch_index, self.attack_data[sketch_index]) + self.sketches.append(sketch) + + def enable_sketch_chances_always(self): + # Always Sketch if the target is valid + # NOPing the JSR and BCS that can prevent Sketch from working + space = Reserve(0x023b3d, 0x023b41, "sketch always", asm.NOP()) + + def enable_sketch_casters_stats(self): + # Based on https://www.ff6hacking.com/forums/thread-3478.html + + # New subroutine. Note that most of this logic is the same as vanilla logic at C2/2954. + src = [ + # A = Character using sketch (from $3417) + asm.BMI("exit"), #Branch if no Sketcher + asm.TAX(), #if there's a valid Sketcher, use their Level for attack by making X = character offset + asm.LDA(0x11A2, asm.ABS), #Spell Properties + asm.LSR(), #Check if Physical/Magical + asm.LDA(0x3B41, asm.ABS_X), #Sketcher's Mag.Pwr + asm.BCC("magical"), #Branch if not physical damage + asm.LDA(0x3B2C, asm.ABS_X), #Sketcher's Vigor * 2 + "magical", + asm.STA(0x11AE, asm.ABS), #Set Sketcher's Magic or Vigor + "exit", + asm.RTS(), + ] + space = Write(Bank.C2, src, "Sketch Caster Stats") + use_sketcher_stats_addr = space.start_address + + # Call our new subroutine + space = Reserve(0x22c25, 0x22c27, "jump to new routine") + space.write( + asm.JSR(use_sketcher_stats_addr, asm.ABS) + ) + + def enable_sketch_improved_abilities(self): + from data.spell_names import name_id + from data.sketch_custom_commands import custom_commands + + for sketch in self.sketches: + # if either is Battle, replace with opposite + if sketch.rare == name_id["Battle"]: + sketch.rare = sketch.common + if sketch.common == name_id["Battle"]: + sketch.common = sketch.rare + # If both are Battle, replace both with Rage (if it exists) + if sketch.rare == name_id["Battle"] and sketch.common == name_id["Battle"]: + if sketch.id < self.rages.RAGE_COUNT: + rage = self.rages.rages[sketch.id] + sketch.rare = rage.attack2 + sketch.common = rage.attack2 + # If both are identical, replace rare with Rage (if it exists) + if sketch.rare == sketch.common: + if sketch.id < self.rages.RAGE_COUNT: + sketch.rare = self.rages.rages[sketch.id].attack2 + # Override with custom commands + if sketch.id in custom_commands: + sketch.rare = custom_commands[sketch.id][0] + sketch.common = custom_commands[sketch.id][1] + + def mod(self): + if self.args.sketch_control_improved_stats: + self.enable_sketch_chances_always() + self.enable_sketch_casters_stats() + if self.args.sketch_control_improved_abilities: + self.enable_sketch_improved_abilities() + + def write(self): + if self.args.spoiler_log: + self.log() + + for sketch_index, sketch in enumerate(self.sketches): + self.attack_data[sketch_index] = sketch.attack_data() + + self.attack_data.write() + + def log(self): + pass + + def print(self): + for sketch in self.sketches: + sketch.print() diff --git a/data/steal.py b/data/steal.py index aeb40be2..f71e965c 100644 --- a/data/steal.py +++ b/data/steal.py @@ -1,79 +1,79 @@ -from memory.space import Bank, Reserve, Allocate, Write -import instruction.asm as asm - -class Steal: - def __init__(self, rom, args): - self.rom = rom - self.args = args - - def enable_steal_chances_always(self): - #Always steal if the enemy has an item. - # If the enemy has both rare and common, the rare item will be stolen 3/8 of the time. - space = Reserve(0x0239b7, 0x0239e4, "steal common vs rare logic", asm.NOP()) - space.add_label("SPACE_END", space.end_address + 1) - space.write( - asm.PHY(), - asm.PHX(), - asm.LDX(0x3308, asm.ABS_Y), # x = rare item - asm.INY(), - asm.LDA(0x3308, asm.ABS_Y), # a = common item - asm.TAY(), # y = common item - - asm.CPX(0xff, asm.IMM8), # rare steal exists? - asm.BEQ("STEAL_COMMON"), # if not, steal common item - asm.CPY(0xff, asm.IMM8), # common steal exists? - asm.BEQ("STEAL_RARE"), # if not, steal rare item - asm.JSR(0x4b5a, asm.ABS), # a = random number between 0 and 255 - asm.CMP(0x60, asm.IMM8), # compare with 96 - asm.BLT("STEAL_RARE"), # if a < 96 then steal rare item - - "STEAL_COMMON", - asm.TYA(), # a = common item - asm.BRA("STEAL_ITEM"), # steal common item - - "STEAL_RARE", - asm.TXA(), # a = rare item - - "STEAL_ITEM", - asm.PLX(), - asm.PLY(), - asm.BRA("SPACE_END"), # skip nops - ) - - def enable_steal_chances_higher(self): - # Increase the Constant added to Attacker's Level from 50 (0x32) to 90 (0x5A) - # Effectively increases chance of stealing for same-level targets from 50% to 90% - # Reference on Steal function (starts at C2 399E): - # StealValue = Attacker's level + Constant - Target's level - # If Thief Glove equipped: StealValue *= 2 - # If StealValue <= 0 then steal fails - # If StealValue >= 128 then you automatically steal - # If StealValue < Random Value (0-99), then you fail to steal - # Else Steal is successful - space = Reserve(0x239BB, 0x239BB, "steal value constant") - space.write(0x5A) # default: 0x32 - - # Increase the Rare Steal Constant from 32 (0x20) to 96 (0x60) - # Effectively increases probably of stealing a rare item from 1/8 to 3/8 - # Occurs after the StealValue calculation above - # Reference on Rare Steals formula (starts at C2 39DB): - # Load Rare Item into Item-to-Steal slot - # If Rare Steal Constant > Random Value (0-255), <- this occurs 7/8 of the time - # load Common item into Item-to-Steal slot instead - # If Item-to-Steal is not empty, acquire it and set both Common and Rare Items to empty - # Else Fail to steal - space = Reserve(0x239DD, 0x239DD, "rare steal constant") - space.write(0x60) # default: 0x20 - - def mod(self): - if self.args.steal_chances_higher: - self.enable_steal_chances_higher() - elif self.args.steal_chances_always: - self.enable_steal_chances_always() - - def write(self): - if self.args.spoiler_log: - self.log() - - def log(self): - pass +from memory.space import Bank, Reserve, Allocate, Write +import instruction.asm as asm + +class Steal: + def __init__(self, rom, args): + self.rom = rom + self.args = args + + def enable_steal_chances_always(self): + #Always steal if the enemy has an item. + # If the enemy has both rare and common, the rare item will be stolen 3/8 of the time. + space = Reserve(0x0239b7, 0x0239e4, "steal common vs rare logic", asm.NOP()) + space.add_label("SPACE_END", space.end_address + 1) + space.write( + asm.PHY(), + asm.PHX(), + asm.LDX(0x3308, asm.ABS_Y), # x = rare item + asm.INY(), + asm.LDA(0x3308, asm.ABS_Y), # a = common item + asm.TAY(), # y = common item + + asm.CPX(0xff, asm.IMM8), # rare steal exists? + asm.BEQ("STEAL_COMMON"), # if not, steal common item + asm.CPY(0xff, asm.IMM8), # common steal exists? + asm.BEQ("STEAL_RARE"), # if not, steal rare item + asm.JSR(0x4b5a, asm.ABS), # a = random number between 0 and 255 + asm.CMP(0x60, asm.IMM8), # compare with 96 + asm.BLT("STEAL_RARE"), # if a < 96 then steal rare item + + "STEAL_COMMON", + asm.TYA(), # a = common item + asm.BRA("STEAL_ITEM"), # steal common item + + "STEAL_RARE", + asm.TXA(), # a = rare item + + "STEAL_ITEM", + asm.PLX(), + asm.PLY(), + asm.BRA("SPACE_END"), # skip nops + ) + + def enable_steal_chances_higher(self): + # Increase the Constant added to Attacker's Level from 50 (0x32) to 90 (0x5A) + # Effectively increases chance of stealing for same-level targets from 50% to 90% + # Reference on Steal function (starts at C2 399E): + # StealValue = Attacker's level + Constant - Target's level + # If Thief Glove equipped: StealValue *= 2 + # If StealValue <= 0 then steal fails + # If StealValue >= 128 then you automatically steal + # If StealValue < Random Value (0-99), then you fail to steal + # Else Steal is successful + space = Reserve(0x239BB, 0x239BB, "steal value constant") + space.write(0x5A) # default: 0x32 + + # Increase the Rare Steal Constant from 32 (0x20) to 96 (0x60) + # Effectively increases probably of stealing a rare item from 1/8 to 3/8 + # Occurs after the StealValue calculation above + # Reference on Rare Steals formula (starts at C2 39DB): + # Load Rare Item into Item-to-Steal slot + # If Rare Steal Constant > Random Value (0-255), <- this occurs 7/8 of the time + # load Common item into Item-to-Steal slot instead + # If Item-to-Steal is not empty, acquire it and set both Common and Rare Items to empty + # Else Fail to steal + space = Reserve(0x239DD, 0x239DD, "rare steal constant") + space.write(0x60) # default: 0x20 + + def mod(self): + if self.args.steal_chances_higher: + self.enable_steal_chances_higher() + elif self.args.steal_chances_always: + self.enable_steal_chances_always() + + def write(self): + if self.args.spoiler_log: + self.log() + + def log(self): + pass diff --git a/data/title_graphics.py b/data/title_graphics.py index 1bbb6f56..e9c56e57 100644 --- a/data/title_graphics.py +++ b/data/title_graphics.py @@ -1,23 +1,23 @@ -from memory.space import Reserve -class TitleGraphics: - def __init__(self, rom, args): - self.rom = rom - self.args = args - - def mod(self): - # Read in the title graphics bin and write it to 18f000 - 194e95 - with open('graphics/title/WC Spartan Title Data-CDude.bin', "rb") as binFile: - data = binFile.read() - - space = Reserve(0x18f000, 0x194e95, "title graphics (compressed)") - if len(space) != len(data): - raise ValueError(f"Invalid title graphics bin size ({len(data)} should be {len(space)})") - - space.write(data) - - def write(self): - if self.args.spoiler_log: - self.log() - - def log(self): +from memory.space import Reserve +class TitleGraphics: + def __init__(self, rom, args): + self.rom = rom + self.args = args + + def mod(self): + # Read in the title graphics bin and write it to 18f000 - 194e95 + with open('graphics/title/WC Spartan Title Data-CDude.bin', "rb") as binFile: + data = binFile.read() + + space = Reserve(0x18f000, 0x194e95, "title graphics (compressed)") + if len(space) != len(data): + raise ValueError(f"Invalid title graphics bin size ({len(data)} should be {len(space)})") + + space.write(data) + + def write(self): + if self.args.spoiler_log: + self.log() + + def log(self): pass \ No newline at end of file diff --git a/data/world_map.py b/data/world_map.py index 2107ca53..aa2521ca 100644 --- a/data/world_map.py +++ b/data/world_map.py @@ -1,56 +1,56 @@ -from memory.space import Reserve - -class WorldMap: - def __init__(self, rom, args): - self.rom = rom - self.args = args - - def world_minimap_high_contrast_mod(self): - # Thanks to Osteoclave for identifying these changes - - # Increases the sprite priority for the minimap sprites - # So it gets drawn on top of the overworld instead of being translucent - #ee4146=1b - space = Reserve(0x2e4146, 0x2e4146, "minimap sprite priority") - space.write(0x1b) # default: 0x0b - - # Colors bytes: gggrrrrr, xbbbbbgg - # High contrast location indicator on minimaps - # d2eeb8=ff + d2eeb9=7f - # d2efb8=ff + d2efb9=7f - location_indicator_addr = [0x12eeb8, # WoB default: 1100 - 0x12efb8] # WoR default: 1100 - for loc_addr in location_indicator_addr: - space = Reserve(loc_addr, loc_addr+1, "high contrast minimap indicator") - space.write(0xff, 0x7f) - - # d2eeba=ff + d2eebb=7f - # d2efba=ff + d2efbb=7f - location_indicator_addr = [0x12eeba, # WoB default: 1f00 - 0x12efba] # WoR default: 1f00 - for loc_addr in location_indicator_addr: - space = Reserve(loc_addr, loc_addr+1, "high contrast minimap indicator") - space.write(0xff, 0x7f) - - # Additional minimap palette mods - # default: 84 10 e7 1c 4a 29 10 42 ff 7f - # WoB: d2eea2=00 + d2eea3=14 + d2eea4=82 + d2eea5=28 + d2eea6=e4 + d2eea7=38 + d2eea8=67 + d2eea9=51 + d2eeaa=9c + d2eeab=02 - # WoR: d2efa2=00 + d2efa3=14 + d2efa4=82 + d2efa5=28 + d2efa6=e4 + d2efa7=38 + d2efa8=67 + d2efa9=51 + d2efaa=9c + d2efab=02 - minimap_palette_bytes = [0x00, 0x14, 0x82, 0x28, 0xe4, 0x38, 0x67, 0x51, 0x9c, 0x02] - minimap_palette_addr = [0x12eea2, # WoB - 0x12efa2] # WoR - for addr in minimap_palette_addr: - space = Reserve(addr, addr+len(minimap_palette_bytes)-1, "minimap palette") - space.write(minimap_palette_bytes) - - # This changes the color of the Floating Continent (pre-floating) on WoB - # default: e7 1c 4a 29 10 42 - # d2eeac=82 + d2eead=28 + d2eeae=e4 + d2eeaf=38 + d2eeb0=67 + d2eeb1=51 - addr = 0x12eeac - minimap_palette_bytes = [0x82, 0x28, 0xe4, 0x38, 0x67, 0x51] - space = Reserve(addr, addr+len(minimap_palette_bytes)-1, "floating continent palette") - space.write(minimap_palette_bytes) - - def mod(self): - if self.args.world_minimap_high_contrast: +from memory.space import Reserve + +class WorldMap: + def __init__(self, rom, args): + self.rom = rom + self.args = args + + def world_minimap_high_contrast_mod(self): + # Thanks to Osteoclave for identifying these changes + + # Increases the sprite priority for the minimap sprites + # So it gets drawn on top of the overworld instead of being translucent + #ee4146=1b + space = Reserve(0x2e4146, 0x2e4146, "minimap sprite priority") + space.write(0x1b) # default: 0x0b + + # Colors bytes: gggrrrrr, xbbbbbgg + # High contrast location indicator on minimaps + # d2eeb8=ff + d2eeb9=7f + # d2efb8=ff + d2efb9=7f + location_indicator_addr = [0x12eeb8, # WoB default: 1100 + 0x12efb8] # WoR default: 1100 + for loc_addr in location_indicator_addr: + space = Reserve(loc_addr, loc_addr+1, "high contrast minimap indicator") + space.write(0xff, 0x7f) + + # d2eeba=ff + d2eebb=7f + # d2efba=ff + d2efbb=7f + location_indicator_addr = [0x12eeba, # WoB default: 1f00 + 0x12efba] # WoR default: 1f00 + for loc_addr in location_indicator_addr: + space = Reserve(loc_addr, loc_addr+1, "high contrast minimap indicator") + space.write(0xff, 0x7f) + + # Additional minimap palette mods + # default: 84 10 e7 1c 4a 29 10 42 ff 7f + # WoB: d2eea2=00 + d2eea3=14 + d2eea4=82 + d2eea5=28 + d2eea6=e4 + d2eea7=38 + d2eea8=67 + d2eea9=51 + d2eeaa=9c + d2eeab=02 + # WoR: d2efa2=00 + d2efa3=14 + d2efa4=82 + d2efa5=28 + d2efa6=e4 + d2efa7=38 + d2efa8=67 + d2efa9=51 + d2efaa=9c + d2efab=02 + minimap_palette_bytes = [0x00, 0x14, 0x82, 0x28, 0xe4, 0x38, 0x67, 0x51, 0x9c, 0x02] + minimap_palette_addr = [0x12eea2, # WoB + 0x12efa2] # WoR + for addr in minimap_palette_addr: + space = Reserve(addr, addr+len(minimap_palette_bytes)-1, "minimap palette") + space.write(minimap_palette_bytes) + + # This changes the color of the Floating Continent (pre-floating) on WoB + # default: e7 1c 4a 29 10 42 + # d2eeac=82 + d2eead=28 + d2eeae=e4 + d2eeaf=38 + d2eeb0=67 + d2eeb1=51 + addr = 0x12eeac + minimap_palette_bytes = [0x82, 0x28, 0xe4, 0x38, 0x67, 0x51] + space = Reserve(addr, addr+len(minimap_palette_bytes)-1, "floating continent palette") + space.write(minimap_palette_bytes) + + def mod(self): + if self.args.world_minimap_high_contrast: self.world_minimap_high_contrast_mod() \ No newline at end of file diff --git a/docs/SPRITE_FORMAT.md b/docs/SPRITE_FORMAT.md new file mode 100644 index 00000000..259bc113 --- /dev/null +++ b/docs/SPRITE_FORMAT.md @@ -0,0 +1,68 @@ +# Sprite, Portrait, and Palette File Formats + +Reference for creating custom character sprites, portraits, and palettes +(the files in `graphics/sprites/custom/`, `graphics/portraits/custom/`, and +`graphics/palettes/custom/`). The implementation lives in `graphics/bgr15.py`, +`graphics/sprite_tile.py`, `graphics/sprite.py`, and `graphics/palette.py`. + +The PNG conversion tools in `graphics/tools/` (`png_sprite.py`, +`png_portrait.py`) produce these formats from images (they require Pillow). + +## Palette files (`.pal`) + +A `.pal` file is a sequence of SNES BGR15 colors, 2 bytes per color, +little-endian. Character palettes are 16 colors = 32 bytes. + +BGR15 bit layout (15 bits used of 16): + +``` +0bbbbbgg gggrrrrr (msb ... lsb of the 16-bit little-endian word) +``` + +Each component is 5 bits (0-31). The randomizer converts to 8-bit RGB by +multiplying by 8, and back by integer-dividing by 8 — so RGB values are +effectively quantized to multiples of 8. + +Color index 0 is transparent in-game. + +## Sprite and portrait files (`.bin`) + +A `.bin` file is a sequence of SNES 4bpp 8x8 tiles, 32 bytes per tile, with +no header. Each pixel is a 4-bit index into the 16-color palette. + +Tile bit-packing (the SNES planar format): each 8-pixel row is built from +4 bytes that each contribute one bit per pixel. For row `r` (0-7) of a tile, +the four bytes are at offsets: + +``` +plane 0 (bit 0): 2*r +plane 1 (bit 1): 2*r + 1 +plane 2 (bit 2): 16 + 2*r +plane 3 (bit 3): 16 + 2*r + 1 +``` + +Within each byte, bit 7 is the leftmost pixel. A worked example is in the +comments at the top of `graphics/sprite_tile.py`. + +## Expected sizes + +| File | Size | Content | +|---|---|---| +| Character/portrait palette `.pal` | 32 bytes | 16 BGR15 colors | +| Portrait `.bin` | 800 bytes | 25 tiles (5x5 tiles = 40x40 pixels) | +| Character sprite `.bin` | tile multiple | poses as listed in `graphics/poses.py` | + +Character sprite files shorter than the slot they replace are padded with +zeros (the missing poses render invisible in-game); longer files are +truncated. Portraits and palettes must match the expected size exactly or +seed generation raises a `ValueError` naming the file. + +## Adding a custom sprite to the randomizer + +1. Place the `.bin`/`.pal` files in the matching `graphics/*/custom/` + directory. The filename convention is `Name-Author-Source.bin`. +2. Register the file in the id tables used by `graphics/sprites/sprites.py`, + `graphics/portraits/portraits.py`, or `graphics/palettes/palettes.py`. +3. Verify with a build: `python3 wc.py -i ff3.smc -o tests/out.smc -cspr ...` + (see `args/graphics.py` for the flag formats), or render to an image with + `Sprite.write_ppm`. diff --git a/event/debug_room.py b/event/debug_room.py index 87b58724..c3c62347 100644 --- a/event/debug_room.py +++ b/event/debug_room.py @@ -1,83 +1,83 @@ -from event.event import * -from data.npc import NPC -from music.song_utils import get_character_theme - -class DebugRoom(Event): - # Using the 3 Scenarios room as our debug map - DEBUG_ROOM = 0x9 - - def name(self): - return "Debug Room" - - def init_event_bits(self, space): - pass - - def remove_npcs_mod(self): - # Remove all existing NPCs - while(self.maps.get_npc_count(self.DEBUG_ROOM) > 0): - self.maps.remove_npc(self.DEBUG_ROOM, 0) - - def _add_recruit_npc(self, character, x, y, direction): - # Add an NPC to recruit each character - src = [ - field.RecruitCharacter(character), - field.PlaySoundEffect(150), - field.StartSong(get_character_theme(character)), - field.Return(), - ] - space = Write(Bank.CC, src, "Recruit NPC") - - recruit_npc = NPC() - recruit_npc.x = x - recruit_npc.y = y - recruit_npc.direction = direction - recruit_npc.sprite = self.characters.get_sprite(character) - recruit_npc.palette = self.characters.get_palette(character) - recruit_npc.set_event_address(space.start_address) - self.maps.append_npc(self.DEBUG_ROOM, recruit_npc) - - def add_recruit_npcs_mod(self): - self._add_recruit_npc(self.characters.TERRA, 1, 8, direction.DOWN) - self._add_recruit_npc(self.characters.LOCKE, 2, 8, direction.DOWN) - self._add_recruit_npc(self.characters.CYAN, 3, 8, direction.DOWN) - self._add_recruit_npc(self.characters.SHADOW, 4, 8, direction.DOWN) - self._add_recruit_npc(self.characters.EDGAR, 5, 8, direction.DOWN) - self._add_recruit_npc(self.characters.SABIN, 6, 8, direction.DOWN) - self._add_recruit_npc(self.characters.CELES, 7, 8, direction.DOWN) - self._add_recruit_npc(self.characters.STRAGO, 8, 8, direction.DOWN) - self._add_recruit_npc(self.characters.RELM, 9, 8, direction.DOWN) - self._add_recruit_npc(self.characters.SETZER, 10, 8, direction.DOWN) - self._add_recruit_npc(self.characters.MOG, 11, 8, direction.DOWN) - self._add_recruit_npc(self.characters.GAU, 12, 8, direction.DOWN) - self._add_recruit_npc(self.characters.GOGO, 13, 8, direction.DOWN) - self._add_recruit_npc(self.characters.UMARO, 14, 8, direction.DOWN) - - def _add_teleport_npc(self, source_map, source_x, source_y, direction, dest_map, dest_x, dest_y): - # Test code to add a Marshal battle NPC to Blackjack - from data.bosses import name_pack - src = [ - field.LoadMap(dest_map, direction, True, dest_x, dest_y, fade_in = True), - field.Return(), - ] - space = Write(Bank.CC, src, "Teleport NPC") - - teleport_npc = NPC() - teleport_npc.x = source_x - teleport_npc.y = source_y - teleport_npc.sprite = 22 - teleport_npc.palette = 3 - teleport_npc.direction = direction - teleport_npc.set_event_address(space.start_address) - self.maps.append_npc(source_map, teleport_npc) - - def add_teleport_npcs_mod(self): - # get to and from the debug room via WoB Airship - BLACKJACK_EXTERIOR_MAP = 0x06 - self._add_teleport_npc(BLACKJACK_EXTERIOR_MAP, 15, 4, direction.DOWN, self.DEBUG_ROOM, 8, 9) - self._add_teleport_npc(self.DEBUG_ROOM, 8, 10, direction.UP, BLACKJACK_EXTERIOR_MAP, 15, 5) - - def mod(self): - if self.args.debug: - self.remove_npcs_mod() - self.add_recruit_npcs_mod() - self.add_teleport_npcs_mod() +from event.event import * +from data.npc import NPC +from music.song_utils import get_character_theme + +class DebugRoom(Event): + # Using the 3 Scenarios room as our debug map + DEBUG_ROOM = 0x9 + + def name(self): + return "Debug Room" + + def init_event_bits(self, space): + pass + + def remove_npcs_mod(self): + # Remove all existing NPCs + while(self.maps.get_npc_count(self.DEBUG_ROOM) > 0): + self.maps.remove_npc(self.DEBUG_ROOM, 0) + + def _add_recruit_npc(self, character, x, y, direction): + # Add an NPC to recruit each character + src = [ + field.RecruitCharacter(character), + field.PlaySoundEffect(150), + field.StartSong(get_character_theme(character)), + field.Return(), + ] + space = Write(Bank.CC, src, "Recruit NPC") + + recruit_npc = NPC() + recruit_npc.x = x + recruit_npc.y = y + recruit_npc.direction = direction + recruit_npc.sprite = self.characters.get_sprite(character) + recruit_npc.palette = self.characters.get_palette(character) + recruit_npc.set_event_address(space.start_address) + self.maps.append_npc(self.DEBUG_ROOM, recruit_npc) + + def add_recruit_npcs_mod(self): + self._add_recruit_npc(self.characters.TERRA, 1, 8, direction.DOWN) + self._add_recruit_npc(self.characters.LOCKE, 2, 8, direction.DOWN) + self._add_recruit_npc(self.characters.CYAN, 3, 8, direction.DOWN) + self._add_recruit_npc(self.characters.SHADOW, 4, 8, direction.DOWN) + self._add_recruit_npc(self.characters.EDGAR, 5, 8, direction.DOWN) + self._add_recruit_npc(self.characters.SABIN, 6, 8, direction.DOWN) + self._add_recruit_npc(self.characters.CELES, 7, 8, direction.DOWN) + self._add_recruit_npc(self.characters.STRAGO, 8, 8, direction.DOWN) + self._add_recruit_npc(self.characters.RELM, 9, 8, direction.DOWN) + self._add_recruit_npc(self.characters.SETZER, 10, 8, direction.DOWN) + self._add_recruit_npc(self.characters.MOG, 11, 8, direction.DOWN) + self._add_recruit_npc(self.characters.GAU, 12, 8, direction.DOWN) + self._add_recruit_npc(self.characters.GOGO, 13, 8, direction.DOWN) + self._add_recruit_npc(self.characters.UMARO, 14, 8, direction.DOWN) + + def _add_teleport_npc(self, source_map, source_x, source_y, direction, dest_map, dest_x, dest_y): + # Test code to add a Marshal battle NPC to Blackjack + from data.bosses import name_pack + src = [ + field.LoadMap(dest_map, direction, True, dest_x, dest_y, fade_in = True), + field.Return(), + ] + space = Write(Bank.CC, src, "Teleport NPC") + + teleport_npc = NPC() + teleport_npc.x = source_x + teleport_npc.y = source_y + teleport_npc.sprite = 22 + teleport_npc.palette = 3 + teleport_npc.direction = direction + teleport_npc.set_event_address(space.start_address) + self.maps.append_npc(source_map, teleport_npc) + + def add_teleport_npcs_mod(self): + # get to and from the debug room via WoB Airship + BLACKJACK_EXTERIOR_MAP = 0x06 + self._add_teleport_npc(BLACKJACK_EXTERIOR_MAP, 15, 4, direction.DOWN, self.DEBUG_ROOM, 8, 9) + self._add_teleport_npc(self.DEBUG_ROOM, 8, 10, direction.UP, BLACKJACK_EXTERIOR_MAP, 15, 5) + + def mod(self): + if self.args.debug: + self.remove_npcs_mod() + self.add_recruit_npcs_mod() + self.add_teleport_npcs_mod() diff --git a/event/event_reward.py b/event/event_reward.py index b8a8ed3b..a1d43945 100644 --- a/event/event_reward.py +++ b/event/event_reward.py @@ -49,7 +49,9 @@ def choose_reward(possible_types, characters, espers, items): # tried all possible_rewards and none were available # probably running out of chars and espers and need to make item rewards possible for more events - assert(item_possible) + if not item_possible: + raise RuntimeError(f"choose_reward: no rewards available for types {possible_types}: " + "characters/espers are exhausted and this slot does not allow items") return (items.get_good_random(), RewardType.ITEM) # Documentation from AtmaTek: diff --git a/event/events.py b/event/events.py index c5c072a7..c559ee4f 100644 --- a/event/events.py +++ b/event/events.py @@ -21,6 +21,14 @@ def __init__(self, rom, args, data): def mod(self): # generate list of events from files + # each event lives in its own module and is discovered by naming + # convention: the class name must equal the module name with + # underscores removed, case-insensitive (e.g. mt_kolts.py -> MtKolts). + # a file without a matching class contributes no event (helpers like + # event_reward.py rely on this, but it also means a typo in a new + # event's class name makes it silently skipped). events load in + # sorted filename order, so an event's __init__ can only look up + # alphabetically-earlier events in name_event import os, importlib, inspect from event.event import Event events = [] @@ -126,6 +134,10 @@ def character_gating_mod(self, events, name_event): unlocked_slot_iterations.append(slot_iterations[slot]) # pick slot for the next character weighted by number of iterations each slot has been available + if not unlocked_slots: + raise RuntimeError("character gating deadlock: " + f"{self.characters.get_available_count()} characters left to assign " + "but no unfilled character slots are currently unlocked") slot_index = weighted_reward_choice(unlocked_slot_iterations, iteration) slot = unlocked_slots[slot_index] slot.id = self.characters.get_random_available() @@ -171,4 +183,5 @@ def validate(self, events): for event in events: char_esper_checks += [r for r in event.rewards if r.possible_types == (RewardType.CHARACTER | RewardType.ESPER)] - assert len(char_esper_checks) == CHARACTER_ESPER_ONLY_REWARDS, f"Number of char/esper only checks changed - Check usages of CHARACTER_ESPER_ONLY_REWARDS and ensure no breaking changes. Expected: {CHARACTER_ESPER_ONLY_REWARDS}, Actual: {len(char_esper_checks)}" + if len(char_esper_checks) != CHARACTER_ESPER_ONLY_REWARDS: + raise RuntimeError(f"Number of char/esper only checks changed - Check usages of CHARACTER_ESPER_ONLY_REWARDS and ensure no breaking changes. Expected: {CHARACTER_ESPER_ONLY_REWARDS}, Actual: {len(char_esper_checks)}") diff --git a/event/imperial_base.py b/event/imperial_base.py index 7212d565..c2700768 100644 --- a/event/imperial_base.py +++ b/event/imperial_base.py @@ -14,16 +14,12 @@ def mod(self): self.entrance_event_mod() def entrance_event_mod(self): - SOLDIERS_BATTLE_ON_TOUCH = 0xb25b9 - space = Reserve(0xb25d6, 0xb25f8, "imperial base entrance event conditions", field.NOP()) if self.args.character_gating: space.write( - #field.BranchIfEventBitSet(event_bit.character_recruited(self.events["Sealed Gate"].character_gate()), SOLDIERS_BATTLE_ON_TOUCH), field.ReturnIfEventBitSet(event_bit.character_recruited(self.events["Sealed Gate"].character_gate())), ) else: space.write( - #field.Branch(SOLDIERS_BATTLE_ON_TOUCH), field.Return(), ) diff --git a/event/kefka_tower.py b/event/kefka_tower.py index 6413ce77..9f5885ef 100644 --- a/event/kefka_tower.py +++ b/event/kefka_tower.py @@ -1,5 +1,4 @@ from event.event import * -import args class KefkaTower(Event): def name(self): diff --git a/event/narshe_moogle_defense.py b/event/narshe_moogle_defense.py index edd6abcc..b97b1f5c 100644 --- a/event/narshe_moogle_defense.py +++ b/event/narshe_moogle_defense.py @@ -1,558 +1,558 @@ -from event.event import * -import data.npc_bit as npc_bit -from constants.entities import character_id -import data.direction -from data.npc import NPC - -class NarsheMoogleDefense(Event): - WOB_MAP_ID = 0x33 - MARSHAL_NPC_ID = 0x12 - LEFT_MOOGLE_NPC_ID = 0x10 - RIGHT_MOOGLE_NPC_ID = 0x11 - COLLAPSED_TERRA_NPC_ID = 0x19 - - def name(self): - return "Narshe Moogle Defense" - - def character_gate(self): - return self.characters.MOG - - def init_rewards(self): - self.reward = self.add_reward(RewardType.CHARACTER | RewardType.ESPER | RewardType.ITEM) - - def init_event_bits(self, space): - space.write( - field.SetEventBit(npc_bit.ARVIS_INTRO), # show Arvis - field.ClearEventBit(npc_bit.MARSHAL_NARSHE_WOB), # do not show marshal - ) - - def _add_moogle_to_party_src(self, party_idx): - # Event logic to add a moogle to the given party - - # map of characters to replacement moogles. Our logic will be to replace any characters not in our party with their mapped moogle. - # index is the character (0 - 14) -- note: no Terra, Locke, or Umaro replacement - # value is the associated replacement Moogle (-1 = no replacement) - MOOGLE_REPLACEMENT = [ - -1, #TERRA - -1, #LOCKE - 0x12, # CYAN -> KUPEK - 0x13, # SHADOW -> KUPOP - 0x14, # EDGAR -> KUMAMA - 0x15, # SABIN -> KUKU - 0x16, # CELES -> KUTAN - 0x17, # STRAGO -> KUPAN - 0x18, # RELM -> KUSHU - 0x19, # SETZER -> KURIN - 0x0A, # MOG - 0x1A, # GAU -> KURU - 0x1B, # GOGO -> KAMOG - -1, #UMARO - ] - - # Goes through moogles, checking whether they're already created (either them or their associated character) - MOOGLE_CHARACTERS = range(2,13) # range of characters replacable with moogles - src = [] - for character_idx in MOOGLE_CHARACTERS: - moogle_id = MOOGLE_REPLACEMENT[character_idx] - src += [ - # Has the character been recruited (we aren't replacing them due to SetProperties)? - field.LoadRecruitedCharacters(), - field.BranchIfEventBitSet(event_bit.multipurpose(character_idx), f"SKIP_{character_idx}"), - # or, is the character currently in a party (aka, they're a moogle)? - field.LoadCreatedCharacters(), - field.BranchIfEventBitSet(event_bit.multipurpose(character_idx), f"SKIP_{character_idx}"), - #if not, make it a moogle - # Make character look like a moogle - field.SetSprite(character_idx, self.characters.get_sprite(self.characters.MOG)), - field.SetPalette(character_idx, self.characters.get_palette(self.characters.MOG)), - # Give it the name and properties of the moogle - field.SetName(character_idx, moogle_id), - field.SetEquipmentAndCommands(character_idx, moogle_id), - ] - if self.args.start_average_level: - src += [ - # Average character level via field command - example ref: CC/3A2C - field.AverageLevel(character_idx), - field.RestoreHp(character_idx, 0x7f), # restore all HP - field.RestoreMp(character_idx, 0x7f), # restore all MP - ] - src += [ - field.CreateEntity(character_idx), - field.AddCharacterToParty(character_idx, party_idx), - field.Branch("RETURN"), # added 1 - we're done - f"SKIP_{character_idx}", - ] - src += [ - f"RETURN", - field.Return(), - ] - - return src - - def add_moogles_to_parties(self): - # Method for selecting parties or moogle replacements - - self.add_moogle_to_party = [] #note: 0-indexed whereas parties are 1 indexed in code - # Create the needed methods for adding a moogle to a party - for i in range(1,4): - space = Write(Bank.CC, self._add_moogle_to_party_src(i), f"Add moogle to party {i}") - self.add_moogle_to_party.append(space.start_address) - - src = [ - field.FadeOutScreen(), - field.WaitForFade(), - field.Call(field.DELETE_CHARACTERS_NOT_IN_ANY_PARTY), - ] - # special logic for party 1, which will already have party members. - # Here, we add moogles to fill in gaps - src += [ - field.SetParty(1), - # if shadow not in party, remove dog block from him so that KUPOP doesn't have Interceptor - field.BranchIfCharacterInParty(self.characters.SHADOW, "HAVE_SHADOW"), - field.RemoveStatusEffects(self.characters.SHADOW, field.Status.DOG_BLOCK), - "HAVE_SHADOW", - field.BranchIfPartySize(1, "ADD_3"), - field.BranchIfPartySize(2, "ADD_2"), - field.BranchIfPartySize(3, "ADD_1"), - field.BranchIfPartySize(4, "ADD_0"), - "ADD_3", - field.Call(self.add_moogle_to_party[0]), - "ADD_2", - field.Call(self.add_moogle_to_party[0]), - "ADD_1", - field.Call(self.add_moogle_to_party[0]), - "ADD_0", - # this line fixes the issue in which the party appears twice if spot 0 is empty before recruiting - field.HideEntity(field_entity.PARTY0), - ] - - # For parties 2 and 3, just iterate 4 times each - for party in range(2,4): - for party_spot in range(0, 4): - src += [ - field.Call(self.add_moogle_to_party[party-1]) - ] - - src += [ - field.RefreshEntities(), - field.Call(field.DELETE_CHARACTERS_NOT_IN_ANY_PARTY), - ] - - space = Reserve(0xca905, 0xcaa03, "moogle defense party creation", field.NOP()) - space.write( - src, - field.SetEventBit(event_bit.CONTINUE_MUSIC_DURING_BATTLE), # cause locke's theme to keep playing through battles - field.Branch(space.end_address + 1), # skip nops - ) - - def marshal_battle_mod(self): - # Replace Marshal battle - boss_pack_id = self.get_boss("Marshal") - - space = Reserve(0xcadac, 0xcadae, "marshal invoke battle", field.NOP()) - space.write( - field.InvokeBattle(boss_pack_id, check_game_over = False) - ) - - def terra_npc_mod(self): - # Add an NPC to replace Terra during the chase scene in Narshe South Caves (map 50). - # By doing so, it allows us to change her sprite without affecting a party Terra - self.terra_npc = NPC() - self.terra_npc.x = 55 - self.terra_npc.y = 11 - self.terra_npc.direction = direction.UP - self.terra_npc.speed = 0 - self.terra_npc.event_byte = npc_bit.event_byte(npc_bit.MARSHAL_NARSHE_WOB) #dual purpose with showing Marshal NPC - self.terra_npc.event_bit = npc_bit.event_bit(npc_bit.MARSHAL_NARSHE_WOB) - self.terra_npc_id = self.maps.append_npc(50, self.terra_npc) - - # Replace collapsed Terra NPC - self.terra_collapsed_npc = self.maps.get_npc(self.WOB_MAP_ID, self.COLLAPSED_TERRA_NPC_ID) - - # ensure that the terra falls in hole event never triggers, as we're reusing the associated event bit - space = Reserve(0xca2e5, 0xca2e5, "terra falls in hole event start") - space.write( - field.Return() - ) - - def marshal_test_mod(self): - # Test code to add a Marshal battle NPC to Blackjack - from data.bosses import name_pack - src = [ - field.InvokeBattle(name_pack["Marshal"], 17), - field.FadeInScreen(), - field.WaitForFade(), - field.Return(), - ] - space = Write(Bank.CC, src, "TEST Marshal battle") - test_marshal_battle = space.start_address - - test_npc = NPC() - test_npc.x = 16 - test_npc.y = 4 - test_npc.sprite = 52 - test_npc.palette = 0 - test_npc.direction = direction.DOWN - test_npc.speed = 0 - test_npc.set_event_address(test_marshal_battle) - self.maps.append_npc(0x6, test_npc) - - - # Add Item-giver NPC - src = [] - for i in range(0, 10): - src += [ - field.AddItem("Potion", sound_effect = False), - field.AddItem("Fenix Down", sound_effect = False), - field.AddItem("Revivify", sound_effect = False), - field.AddItem("Antidote", sound_effect = False), - field.AddItem("Shuriken", sound_effect = False), - ] - src += [ - field.AddItem("Sniper", sound_effect = False), - field.AddItem("Scimitar", sound_effect = False), - field.AddItem("Force Armor", sound_effect = False), - field.AddItem("Force Shld", sound_effect = False), - field.AddItem("Flash", sound_effect = False), - field.AddItem("Chain Saw", sound_effect = False), - field.AddItem("Ninja Star", sound_effect = False), - field.AddItem("Tack Star", sound_effect = False), - field.AddItem("White Cape", sound_effect = False), - field.AddItem("Jewel Ring", sound_effect = False), - field.AddItem("Fairy Ring", sound_effect = False), - field.AddItem("Barrier Ring", sound_effect = False), - field.AddItem("MithrilGlove", sound_effect = False), - field.AddItem("Guard Ring", sound_effect = False), - field.AddItem("RunningShoes", sound_effect = False), - field.AddItem("Wall Ring", sound_effect = False), - field.AddItem("Cherub Down", sound_effect = False), - field.AddItem("Cure Ring", sound_effect = False), - field.AddItem("Hero Ring", sound_effect = True), - field.Return() - ] - space = Write(Bank.CC, src, "Item Giver Debug NPC") - item_giver = space.start_address - - item_giver_npc = NPC() - item_giver_npc.x = 17 - item_giver_npc.y = 4 - item_giver_npc.sprite = 33 - item_giver_npc.palette = 2 - item_giver_npc.direction = direction.DOWN - item_giver_npc.speed = 0 - item_giver_npc.set_event_address(item_giver) - self.maps.append_npc(0x6, item_giver_npc) - - def marshal_npc_mod(self): - # Change the NPC bit that activates Marshal - marshal_npc = self.maps.get_npc(self.WOB_MAP_ID, 0x12) - marshal_npc.event_byte = npc_bit.event_byte(npc_bit.MARSHAL_NARSHE_WOB) - marshal_npc.event_bit = npc_bit.event_bit(npc_bit.MARSHAL_NARSHE_WOB) - - def arvis_start_mod(self): - NARSHE_OTHER_ROOM_MAP_ID = 0x1e - - # Move Arvis NPC - ARVIS_NPC_ID = 0x11 - arvis_npc = self.maps.get_npc(NARSHE_OTHER_ROOM_MAP_ID, ARVIS_NPC_ID) - arvis_npc.x = 61 - arvis_npc.y = 36 - - # Update Narshe: Other Rooms entrance event - # Hide the 6 NPCs in Mayor's house that share event code with Arvis. - src = [] - for npc_id in range(0x15, 0x1b): - src += [ - field.HideEntity(npc_id) - ] - # - Hide Arvis if in WoR - # - Hide Arvis if character gating and no Mog - src += [ - field.BranchIfEventBitClear(event_bit.IN_WOR, "WOB"), - field.HideEntity(ARVIS_NPC_ID), - "WOB", - ] - if self.args.character_gating: - src += [ - field.BranchIfEventBitSet(event_bit.character_recruited(self.character_gate()), "RETURN"), - field.HideEntity(ARVIS_NPC_ID), - ] - src += [ - "RETURN", - Read(0xc395a, 0xc3965), # displaced code - field.Return(), - ] - space = Write(Bank.CC, src, "narshe moogle defense character gate") - entrance_event = space.start_address - - space = Reserve(0xc395a, 0xc3965, "narshe: other rooms entrance event") - space.write(field.Branch(entrance_event)) - - # Actions after accepting - src = [ - field.FadeOutScreen(), - field.WaitForFade(), - field.HideEntity(field_entity.PARTY0), - field.SetEventBit(npc_bit.MARSHAL_NARSHE_WOB), # Show "Terra" in south caves and Marshal in battle - field.SetEventBit(npc_bit.TERRA_COLLAPSED_NARSHE_WOB), # Show collapsed "Terra" - field.LoadMap(0x32, direction.UP, True, 55, 11), - field.FadeInScreen(), - field.WaitForFade(), - field.Branch(0xCCA2EB) # 'Got her!' scene - ] - space = Write(Bank.CC, src, "load narshe caves map for Terra event") - got_her_map_change = space.start_address - - # Change Arvis Script - prepared_dialog = 0x21 # reuse "OLD MAN: Make your way out through the mines! I’ll keep these brutes occupied!" - self.dialogs.set_text(prepared_dialog, f"Imperial troops are searching the mines as we speak. They must have found something important!Will you stop them? Yes No") - space = Reserve(0xca06f, 0xca07d, "arvis dialog", field.NOP()) - space.write( - field.DialogBranch(prepared_dialog, - dest1 = got_her_map_change, - dest2 = field.RETURN) - ) - - def event_start_mod(self): - #Replace Terra commands in script with new NPC for which we can manipulate the sprite/palette to match the reward - terra_action_queues = [0xCA2EB, 0xCA2F3, 0xCA31F, 0xCA32D, 0xCA34F, 0xCA362, 0xCA371, 0xCA38B, 0xCA390, 0xCA397, 0xCA3BC] - for address in terra_action_queues: - space = Reserve(address, address, "terra chased action queues") - space.write(self.terra_npc_id) - - # Clear got her dialog - space = Reserve(0xca2f0, 0xca2f2, "dialog: Got her", field.NOP()) # 'Got her' dialog - - # clear out Terra's fall & flashback, but show "Locke" (party leader) to allow for drop-down - space = Reserve(0xca3f9, 0xca769, "Terra fall and flashback", field.NOP()) - space.write( - field.ShowEntity(self.COLLAPSED_TERRA_NPC_ID), - field.HideEntity(self.MARSHAL_NPC_ID), - field.ShowEntity(field_entity.PARTY0), - field.RefreshEntities(), - field.StartSong(13), # play song: Locke - field.SetEventBit(event_bit.TEMP_SONG_OVERRIDE), # keep song playing - field.Branch(space.end_address + 1), # skip nops - ) - - # replace overly long pause (~2 seconds) before locke drops down - space = Reserve(0xca778, 0xca778, "pre-locke drop pause") - space.write(field.Pause(0.50)) - - # Change Locke actions to Party Leader - locke_action_queues = [0xca76b, 0xca77b, 0xca786, - 0xca78e, 0xca793 , 0xca799, 0xca79f, - 0xca7a4, 0xca7a8, 0xca7af, 0xca7b3, - 0xca7b8] - # 0xca868 NO-OP'd below - for address in locke_action_queues: - space = Reserve(address, address, "locke drop down to protect terra") - space.write(field_entity.PARTY0) - - # Speed up Marshal coming down stairs - space = Reserve(0xca7dd, 0xca7dd, "marshal normal") - space.write(field_entity.Speed.FAST) - - # Clear guard dialog - space = Reserve(0xca7ee, 0xca7f0, "dialog: Now we gotcha!", field.NOP()) - - # No dialog starting at cc/a85f -- reasonable point to add in the Marshal NPC - space = Reserve(0xca85f, 0xca86f, "dialog: There's a whole bunch of 'em + Kupo", field.NOP()) - space.write( - field.ShowEntity(self.MARSHAL_NPC_ID), # show the Marshal NPC - ) - - # Change moogles starting location to match their location at start of battle - space = Reserve(0xca8ab, 0xca8b1, "moogle 11 moves down left", field.NOP()) - space.write( - # just move down 1 to put at 15,13 - field.EntityAct(self.RIGHT_MOOGLE_NPC_ID, True, - field_entity.Move(direction.DOWN, 1), - ), - ) - - # Remove Locke-Moogle dialog - replace by moving moogle 10 down 1 - space = Reserve(0xca8b2, 0xca8d4, "dialog: Moogles! Are you saying you want to help me? + Nod + dialog: Kupo!!!", field.NOP()) - space.write( - field.EntityAct(self.LEFT_MOOGLE_NPC_ID, True, - field_entity.Turn(direction.DOWN), - field_entity.Move(direction.DOWN, 1), - ) - ) - - # Remove small pause - space = Reserve(0xca8ff, 0xca8ff, "small pause before fade", field.NOP()) - - # Change logic for moogle party selection to account for any party variation - self.add_moogles_to_parties() - - # Add party size checks around the addition of parties 2 and 3 to the map - src = [ - field.BranchIfPartyEmpty(2, "RETURN"), # if there's no party 2, there's no party 3 - Read(0xcaa23, 0xcaa26), # displaced code -- place party 2 on map - field.BranchIfPartyEmpty(3, "RETURN"), - Read(0xcaa27, 0xcaa2a), # displaced code -- place party 3 on map - "RETURN", - field.Return(), - ] - space = Write(Bank.CC, src, "Check for Party 2 and 3 sizes before placing") - place_parties = space.start_address - - space = Reserve(0xcaa23, 0xcaa2a, "place party 2 and 3 on map", field.NOP()) - space.write( - field.Call(place_parties), - ) - - src = [ - field.BranchIfPartyEmpty(2, "RETURN"), # if there's no party 2, there's no party 3 - Read(0xcaa3a, 0xcaa48), # displaced code -- position party 2 on map - field.BranchIfPartyEmpty(3, "RETURN"), - Read(0xcaa49, 0xcaa57), # displaced code -- position party 3 on map - "RETURN", - field.Return(), - ] - space = Write(Bank.CC, src, "Position party 2") - position_parties = space.start_address - - space = Reserve(0xcaa3a, 0xcaa57, "position party 2 on map", field.NOP()) - space.write( - field.Call(position_parties), - ) - - # Clear use of event_bit.12E (TERRA_COLLAPSED_NARHSE_WOB) and event_bit.003 (moogle defense) at cc/aaab so that we can reuse 12E - # and so that 003 doesn't cause issues at WoB Narshe entrance - space = Reserve(0xcaaab, 0xcaaae, "set terra falls event bit & initiated moogle defense bit", field.NOP()) - - def after_battle_mod(self, reward_instructions): - # Loss - remove Locke's name from dialog - self.dialogs.set_text(1744, "Couldn't hold out…!?Uh oh…") - - # Victory condition (marshal defeated) - # Remove moogles from party - src = [ - reward_instructions, - - Read(0xcade5, 0xcadec), # vanilla fade out and pan camera north - - field.ClearEventBit(event_bit.TEMP_SONG_OVERRIDE), # allow song to change on map change - field.ClearEventBit(npc_bit.MARSHAL_NARSHE_WOB), # Remove Marshal and "Terra" in south caves - field.ClearEventBit(npc_bit.TERRA_COLLAPSED_NARSHE_WOB), # Remove collapsed Terra - - Read(0xcaded, 0xcadf2), # load map - - field.HideEntity(0x1B), # the exit block at top of map - - field.SetParty(1), - field.Call(field.REMOVE_ALL_CHARACTERS_FROM_ALL_PARTIES), - field.LoadRecruitedCharacters(), - ] - for character_idx in range(self.characters.CHARACTER_COUNT): - src += [ - #only restore if character has not been recruited (meaning they were moogled) - field.BranchIfEventBitSet(event_bit.multipurpose(character_idx), f"SKIP_{character_idx}"), - field.RemoveStatusEffects(character_idx, field.Status.FLOAT | field.Status.DARKNESS | field.Status.ZOMBIE | field.Status.POISON | field.Status.VANISH | field.Status.IMP | field.Status.PETRIFY | field.Status.DEATH), - field.RemoveDeath(character_idx), # added due to permadeath situations to make sure the corresponding party member is alive - field.RestoreHp(character_idx, 0x7f), # restore all HP - field.RestoreMp(character_idx, 0x7f), # restore all MP - # Restore character appearance, name, and properties - field.SetSprite(character_idx, self.characters.get_sprite(character_idx)), - field.SetPalette(character_idx, self.characters.get_palette(character_idx)), - field.SetName(character_idx, character_idx), - field.SetEquipmentAndCommands(character_idx, character_idx), - f"SKIP_{character_idx}", - ] - src += [ - # give Shadow Interceptor again - field.AddStatusEffects(self.characters.SHADOW, field.Status.DOG_BLOCK), - - field.Call(field.REFRESH_CHARACTERS_AND_SELECT_PARTY), - field.UpdatePartyLeader(), - field.ShowEntity(field_entity.PARTY0), - field.RefreshEntities(), - - field.FreeScreen(), - - field.FadeInScreen(), - field.WaitForFade(), - - field.SetEventBit(event_bit.FINISHED_MOOGLE_DEFENSE), - field.FreeMovement(), - - # hide Arvis - field.ClearEventBit(npc_bit.ARVIS_INTRO), - field.FinishCheck(), - field.Return(), - ] - space = Reserve(0xcade5, 0xcb04f, "moogle defense victory", field.NOP()) - space.write(src) - - def character_mod(self, character): - sprite = character - self.terra_npc.sprite = sprite - self.terra_npc.palette = self.characters.get_palette(sprite) - self.terra_collapsed_npc.sprite = sprite - self.terra_collapsed_npc.palette = self.characters.get_palette(sprite) - - self.after_battle_mod([ - # Restore character appearance, name, and properties - field.SetSprite(character, self.characters.get_sprite(character)), - field.SetPalette(character, self.characters.get_palette(character)), - field.SetName(character, character), - field.SetEquipmentAndCommands(character, character), - field.RemoveStatusEffects(character, field.Status.FLOAT | field.Status.DARKNESS | field.Status.ZOMBIE | field.Status.POISON | field.Status.VANISH | field.Status.IMP | field.Status.PETRIFY | field.Status.DEATH), - field.RemoveDeath(character), # added due to permadeath situations to make sure the corresponding party member is alive - field.RestoreHp(character, 0x7f), # restore all HP - field.RestoreMp(character, 0x7f), # restore all MP - field.RecruitCharacter(character), - ]) - - def esper_item_mod(self, esper_item_instructions): - if self.args.character_gating: - #Using thematic Moogle sprite for Esper/Items - esper_item_sprite = self.characters.get_sprite(self.characters.MOG) - else: - # Open world -- use standard sprites - esper_item_sprite = self.characters.get_random_esper_item_sprite() - self.terra_npc.sprite = esper_item_sprite - self.terra_npc.palette = self.characters.get_palette(self.terra_npc.sprite) - self.terra_collapsed_npc.sprite = esper_item_sprite - self.terra_collapsed_npc.palette = self.characters.get_palette(self.terra_collapsed_npc.sprite) - - self.after_battle_mod(esper_item_instructions) - - def esper_mod(self, esper): - self.esper_item_mod([ - field.AddEsper(esper), - field.Dialog(self.espers.get_receive_esper_dialog(esper)), - ]) - - def item_mod(self, item): - self.esper_item_mod([ - field.AddItem(item), - field.Dialog(self.items.get_receive_dialog(item)), - ]) - - def mod(self): - self.terra_npc_mod() - - if self.args.debug: - self.marshal_test_mod() - - self.marshal_npc_mod() - - self.arvis_start_mod() - self.event_start_mod() - self.marshal_battle_mod() - - if self.reward.type == RewardType.CHARACTER: - self.character_mod(self.reward.id) - elif self.reward.type == RewardType.ESPER: - self.esper_mod(self.reward.id) - elif self.reward.type == RewardType.ITEM: - self.item_mod(self.reward.id) - - self.log_reward(self.reward) - - - - +from event.event import * +import data.npc_bit as npc_bit +from constants.entities import character_id +import data.direction +from data.npc import NPC + +class NarsheMoogleDefense(Event): + WOB_MAP_ID = 0x33 + MARSHAL_NPC_ID = 0x12 + LEFT_MOOGLE_NPC_ID = 0x10 + RIGHT_MOOGLE_NPC_ID = 0x11 + COLLAPSED_TERRA_NPC_ID = 0x19 + + def name(self): + return "Narshe Moogle Defense" + + def character_gate(self): + return self.characters.MOG + + def init_rewards(self): + self.reward = self.add_reward(RewardType.CHARACTER | RewardType.ESPER | RewardType.ITEM) + + def init_event_bits(self, space): + space.write( + field.SetEventBit(npc_bit.ARVIS_INTRO), # show Arvis + field.ClearEventBit(npc_bit.MARSHAL_NARSHE_WOB), # do not show marshal + ) + + def _add_moogle_to_party_src(self, party_idx): + # Event logic to add a moogle to the given party + + # map of characters to replacement moogles. Our logic will be to replace any characters not in our party with their mapped moogle. + # index is the character (0 - 14) -- note: no Terra, Locke, or Umaro replacement + # value is the associated replacement Moogle (-1 = no replacement) + MOOGLE_REPLACEMENT = [ + -1, #TERRA + -1, #LOCKE + 0x12, # CYAN -> KUPEK + 0x13, # SHADOW -> KUPOP + 0x14, # EDGAR -> KUMAMA + 0x15, # SABIN -> KUKU + 0x16, # CELES -> KUTAN + 0x17, # STRAGO -> KUPAN + 0x18, # RELM -> KUSHU + 0x19, # SETZER -> KURIN + 0x0A, # MOG + 0x1A, # GAU -> KURU + 0x1B, # GOGO -> KAMOG + -1, #UMARO + ] + + # Goes through moogles, checking whether they're already created (either them or their associated character) + MOOGLE_CHARACTERS = range(2,13) # range of characters replacable with moogles + src = [] + for character_idx in MOOGLE_CHARACTERS: + moogle_id = MOOGLE_REPLACEMENT[character_idx] + src += [ + # Has the character been recruited (we aren't replacing them due to SetProperties)? + field.LoadRecruitedCharacters(), + field.BranchIfEventBitSet(event_bit.multipurpose(character_idx), f"SKIP_{character_idx}"), + # or, is the character currently in a party (aka, they're a moogle)? + field.LoadCreatedCharacters(), + field.BranchIfEventBitSet(event_bit.multipurpose(character_idx), f"SKIP_{character_idx}"), + #if not, make it a moogle + # Make character look like a moogle + field.SetSprite(character_idx, self.characters.get_sprite(self.characters.MOG)), + field.SetPalette(character_idx, self.characters.get_palette(self.characters.MOG)), + # Give it the name and properties of the moogle + field.SetName(character_idx, moogle_id), + field.SetEquipmentAndCommands(character_idx, moogle_id), + ] + if self.args.start_average_level: + src += [ + # Average character level via field command - example ref: CC/3A2C + field.AverageLevel(character_idx), + field.RestoreHp(character_idx, 0x7f), # restore all HP + field.RestoreMp(character_idx, 0x7f), # restore all MP + ] + src += [ + field.CreateEntity(character_idx), + field.AddCharacterToParty(character_idx, party_idx), + field.Branch("RETURN"), # added 1 - we're done + f"SKIP_{character_idx}", + ] + src += [ + f"RETURN", + field.Return(), + ] + + return src + + def add_moogles_to_parties(self): + # Method for selecting parties or moogle replacements + + self.add_moogle_to_party = [] #note: 0-indexed whereas parties are 1 indexed in code + # Create the needed methods for adding a moogle to a party + for i in range(1,4): + space = Write(Bank.CC, self._add_moogle_to_party_src(i), f"Add moogle to party {i}") + self.add_moogle_to_party.append(space.start_address) + + src = [ + field.FadeOutScreen(), + field.WaitForFade(), + field.Call(field.DELETE_CHARACTERS_NOT_IN_ANY_PARTY), + ] + # special logic for party 1, which will already have party members. + # Here, we add moogles to fill in gaps + src += [ + field.SetParty(1), + # if shadow not in party, remove dog block from him so that KUPOP doesn't have Interceptor + field.BranchIfCharacterInParty(self.characters.SHADOW, "HAVE_SHADOW"), + field.RemoveStatusEffects(self.characters.SHADOW, field.Status.DOG_BLOCK), + "HAVE_SHADOW", + field.BranchIfPartySize(1, "ADD_3"), + field.BranchIfPartySize(2, "ADD_2"), + field.BranchIfPartySize(3, "ADD_1"), + field.BranchIfPartySize(4, "ADD_0"), + "ADD_3", + field.Call(self.add_moogle_to_party[0]), + "ADD_2", + field.Call(self.add_moogle_to_party[0]), + "ADD_1", + field.Call(self.add_moogle_to_party[0]), + "ADD_0", + # this line fixes the issue in which the party appears twice if spot 0 is empty before recruiting + field.HideEntity(field_entity.PARTY0), + ] + + # For parties 2 and 3, just iterate 4 times each + for party in range(2,4): + for party_spot in range(0, 4): + src += [ + field.Call(self.add_moogle_to_party[party-1]) + ] + + src += [ + field.RefreshEntities(), + field.Call(field.DELETE_CHARACTERS_NOT_IN_ANY_PARTY), + ] + + space = Reserve(0xca905, 0xcaa03, "moogle defense party creation", field.NOP()) + space.write( + src, + field.SetEventBit(event_bit.CONTINUE_MUSIC_DURING_BATTLE), # cause locke's theme to keep playing through battles + field.Branch(space.end_address + 1), # skip nops + ) + + def marshal_battle_mod(self): + # Replace Marshal battle + boss_pack_id = self.get_boss("Marshal") + + space = Reserve(0xcadac, 0xcadae, "marshal invoke battle", field.NOP()) + space.write( + field.InvokeBattle(boss_pack_id, check_game_over = False) + ) + + def terra_npc_mod(self): + # Add an NPC to replace Terra during the chase scene in Narshe South Caves (map 50). + # By doing so, it allows us to change her sprite without affecting a party Terra + self.terra_npc = NPC() + self.terra_npc.x = 55 + self.terra_npc.y = 11 + self.terra_npc.direction = direction.UP + self.terra_npc.speed = 0 + self.terra_npc.event_byte = npc_bit.event_byte(npc_bit.MARSHAL_NARSHE_WOB) #dual purpose with showing Marshal NPC + self.terra_npc.event_bit = npc_bit.event_bit(npc_bit.MARSHAL_NARSHE_WOB) + self.terra_npc_id = self.maps.append_npc(50, self.terra_npc) + + # Replace collapsed Terra NPC + self.terra_collapsed_npc = self.maps.get_npc(self.WOB_MAP_ID, self.COLLAPSED_TERRA_NPC_ID) + + # ensure that the terra falls in hole event never triggers, as we're reusing the associated event bit + space = Reserve(0xca2e5, 0xca2e5, "terra falls in hole event start") + space.write( + field.Return() + ) + + def marshal_test_mod(self): + # Test code to add a Marshal battle NPC to Blackjack + from data.bosses import name_pack + src = [ + field.InvokeBattle(name_pack["Marshal"], 17), + field.FadeInScreen(), + field.WaitForFade(), + field.Return(), + ] + space = Write(Bank.CC, src, "TEST Marshal battle") + test_marshal_battle = space.start_address + + test_npc = NPC() + test_npc.x = 16 + test_npc.y = 4 + test_npc.sprite = 52 + test_npc.palette = 0 + test_npc.direction = direction.DOWN + test_npc.speed = 0 + test_npc.set_event_address(test_marshal_battle) + self.maps.append_npc(0x6, test_npc) + + + # Add Item-giver NPC + src = [] + for i in range(0, 10): + src += [ + field.AddItem("Potion", sound_effect = False), + field.AddItem("Fenix Down", sound_effect = False), + field.AddItem("Revivify", sound_effect = False), + field.AddItem("Antidote", sound_effect = False), + field.AddItem("Shuriken", sound_effect = False), + ] + src += [ + field.AddItem("Sniper", sound_effect = False), + field.AddItem("Scimitar", sound_effect = False), + field.AddItem("Force Armor", sound_effect = False), + field.AddItem("Force Shld", sound_effect = False), + field.AddItem("Flash", sound_effect = False), + field.AddItem("Chain Saw", sound_effect = False), + field.AddItem("Ninja Star", sound_effect = False), + field.AddItem("Tack Star", sound_effect = False), + field.AddItem("White Cape", sound_effect = False), + field.AddItem("Jewel Ring", sound_effect = False), + field.AddItem("Fairy Ring", sound_effect = False), + field.AddItem("Barrier Ring", sound_effect = False), + field.AddItem("MithrilGlove", sound_effect = False), + field.AddItem("Guard Ring", sound_effect = False), + field.AddItem("RunningShoes", sound_effect = False), + field.AddItem("Wall Ring", sound_effect = False), + field.AddItem("Cherub Down", sound_effect = False), + field.AddItem("Cure Ring", sound_effect = False), + field.AddItem("Hero Ring", sound_effect = True), + field.Return() + ] + space = Write(Bank.CC, src, "Item Giver Debug NPC") + item_giver = space.start_address + + item_giver_npc = NPC() + item_giver_npc.x = 17 + item_giver_npc.y = 4 + item_giver_npc.sprite = 33 + item_giver_npc.palette = 2 + item_giver_npc.direction = direction.DOWN + item_giver_npc.speed = 0 + item_giver_npc.set_event_address(item_giver) + self.maps.append_npc(0x6, item_giver_npc) + + def marshal_npc_mod(self): + # Change the NPC bit that activates Marshal + marshal_npc = self.maps.get_npc(self.WOB_MAP_ID, 0x12) + marshal_npc.event_byte = npc_bit.event_byte(npc_bit.MARSHAL_NARSHE_WOB) + marshal_npc.event_bit = npc_bit.event_bit(npc_bit.MARSHAL_NARSHE_WOB) + + def arvis_start_mod(self): + NARSHE_OTHER_ROOM_MAP_ID = 0x1e + + # Move Arvis NPC + ARVIS_NPC_ID = 0x11 + arvis_npc = self.maps.get_npc(NARSHE_OTHER_ROOM_MAP_ID, ARVIS_NPC_ID) + arvis_npc.x = 61 + arvis_npc.y = 36 + + # Update Narshe: Other Rooms entrance event + # Hide the 6 NPCs in Mayor's house that share event code with Arvis. + src = [] + for npc_id in range(0x15, 0x1b): + src += [ + field.HideEntity(npc_id) + ] + # - Hide Arvis if in WoR + # - Hide Arvis if character gating and no Mog + src += [ + field.BranchIfEventBitClear(event_bit.IN_WOR, "WOB"), + field.HideEntity(ARVIS_NPC_ID), + "WOB", + ] + if self.args.character_gating: + src += [ + field.BranchIfEventBitSet(event_bit.character_recruited(self.character_gate()), "RETURN"), + field.HideEntity(ARVIS_NPC_ID), + ] + src += [ + "RETURN", + Read(0xc395a, 0xc3965), # displaced code + field.Return(), + ] + space = Write(Bank.CC, src, "narshe moogle defense character gate") + entrance_event = space.start_address + + space = Reserve(0xc395a, 0xc3965, "narshe: other rooms entrance event") + space.write(field.Branch(entrance_event)) + + # Actions after accepting + src = [ + field.FadeOutScreen(), + field.WaitForFade(), + field.HideEntity(field_entity.PARTY0), + field.SetEventBit(npc_bit.MARSHAL_NARSHE_WOB), # Show "Terra" in south caves and Marshal in battle + field.SetEventBit(npc_bit.TERRA_COLLAPSED_NARSHE_WOB), # Show collapsed "Terra" + field.LoadMap(0x32, direction.UP, True, 55, 11), + field.FadeInScreen(), + field.WaitForFade(), + field.Branch(0xCCA2EB) # 'Got her!' scene + ] + space = Write(Bank.CC, src, "load narshe caves map for Terra event") + got_her_map_change = space.start_address + + # Change Arvis Script + prepared_dialog = 0x21 # reuse "OLD MAN: Make your way out through the mines! I’ll keep these brutes occupied!" + self.dialogs.set_text(prepared_dialog, f"Imperial troops are searching the mines as we speak. They must have found something important!Will you stop them? Yes No") + space = Reserve(0xca06f, 0xca07d, "arvis dialog", field.NOP()) + space.write( + field.DialogBranch(prepared_dialog, + dest1 = got_her_map_change, + dest2 = field.RETURN) + ) + + def event_start_mod(self): + #Replace Terra commands in script with new NPC for which we can manipulate the sprite/palette to match the reward + terra_action_queues = [0xCA2EB, 0xCA2F3, 0xCA31F, 0xCA32D, 0xCA34F, 0xCA362, 0xCA371, 0xCA38B, 0xCA390, 0xCA397, 0xCA3BC] + for address in terra_action_queues: + space = Reserve(address, address, "terra chased action queues") + space.write(self.terra_npc_id) + + # Clear got her dialog + space = Reserve(0xca2f0, 0xca2f2, "dialog: Got her", field.NOP()) # 'Got her' dialog + + # clear out Terra's fall & flashback, but show "Locke" (party leader) to allow for drop-down + space = Reserve(0xca3f9, 0xca769, "Terra fall and flashback", field.NOP()) + space.write( + field.ShowEntity(self.COLLAPSED_TERRA_NPC_ID), + field.HideEntity(self.MARSHAL_NPC_ID), + field.ShowEntity(field_entity.PARTY0), + field.RefreshEntities(), + field.StartSong(13), # play song: Locke + field.SetEventBit(event_bit.TEMP_SONG_OVERRIDE), # keep song playing + field.Branch(space.end_address + 1), # skip nops + ) + + # replace overly long pause (~2 seconds) before locke drops down + space = Reserve(0xca778, 0xca778, "pre-locke drop pause") + space.write(field.Pause(0.50)) + + # Change Locke actions to Party Leader + locke_action_queues = [0xca76b, 0xca77b, 0xca786, + 0xca78e, 0xca793 , 0xca799, 0xca79f, + 0xca7a4, 0xca7a8, 0xca7af, 0xca7b3, + 0xca7b8] + # 0xca868 NO-OP'd below + for address in locke_action_queues: + space = Reserve(address, address, "locke drop down to protect terra") + space.write(field_entity.PARTY0) + + # Speed up Marshal coming down stairs + space = Reserve(0xca7dd, 0xca7dd, "marshal normal") + space.write(field_entity.Speed.FAST) + + # Clear guard dialog + space = Reserve(0xca7ee, 0xca7f0, "dialog: Now we gotcha!", field.NOP()) + + # No dialog starting at cc/a85f -- reasonable point to add in the Marshal NPC + space = Reserve(0xca85f, 0xca86f, "dialog: There's a whole bunch of 'em + Kupo", field.NOP()) + space.write( + field.ShowEntity(self.MARSHAL_NPC_ID), # show the Marshal NPC + ) + + # Change moogles starting location to match their location at start of battle + space = Reserve(0xca8ab, 0xca8b1, "moogle 11 moves down left", field.NOP()) + space.write( + # just move down 1 to put at 15,13 + field.EntityAct(self.RIGHT_MOOGLE_NPC_ID, True, + field_entity.Move(direction.DOWN, 1), + ), + ) + + # Remove Locke-Moogle dialog - replace by moving moogle 10 down 1 + space = Reserve(0xca8b2, 0xca8d4, "dialog: Moogles! Are you saying you want to help me? + Nod + dialog: Kupo!!!", field.NOP()) + space.write( + field.EntityAct(self.LEFT_MOOGLE_NPC_ID, True, + field_entity.Turn(direction.DOWN), + field_entity.Move(direction.DOWN, 1), + ) + ) + + # Remove small pause + space = Reserve(0xca8ff, 0xca8ff, "small pause before fade", field.NOP()) + + # Change logic for moogle party selection to account for any party variation + self.add_moogles_to_parties() + + # Add party size checks around the addition of parties 2 and 3 to the map + src = [ + field.BranchIfPartyEmpty(2, "RETURN"), # if there's no party 2, there's no party 3 + Read(0xcaa23, 0xcaa26), # displaced code -- place party 2 on map + field.BranchIfPartyEmpty(3, "RETURN"), + Read(0xcaa27, 0xcaa2a), # displaced code -- place party 3 on map + "RETURN", + field.Return(), + ] + space = Write(Bank.CC, src, "Check for Party 2 and 3 sizes before placing") + place_parties = space.start_address + + space = Reserve(0xcaa23, 0xcaa2a, "place party 2 and 3 on map", field.NOP()) + space.write( + field.Call(place_parties), + ) + + src = [ + field.BranchIfPartyEmpty(2, "RETURN"), # if there's no party 2, there's no party 3 + Read(0xcaa3a, 0xcaa48), # displaced code -- position party 2 on map + field.BranchIfPartyEmpty(3, "RETURN"), + Read(0xcaa49, 0xcaa57), # displaced code -- position party 3 on map + "RETURN", + field.Return(), + ] + space = Write(Bank.CC, src, "Position party 2") + position_parties = space.start_address + + space = Reserve(0xcaa3a, 0xcaa57, "position party 2 on map", field.NOP()) + space.write( + field.Call(position_parties), + ) + + # Clear use of event_bit.12E (TERRA_COLLAPSED_NARHSE_WOB) and event_bit.003 (moogle defense) at cc/aaab so that we can reuse 12E + # and so that 003 doesn't cause issues at WoB Narshe entrance + space = Reserve(0xcaaab, 0xcaaae, "set terra falls event bit & initiated moogle defense bit", field.NOP()) + + def after_battle_mod(self, reward_instructions): + # Loss - remove Locke's name from dialog + self.dialogs.set_text(1744, "Couldn't hold out…!?Uh oh…") + + # Victory condition (marshal defeated) + # Remove moogles from party + src = [ + reward_instructions, + + Read(0xcade5, 0xcadec), # vanilla fade out and pan camera north + + field.ClearEventBit(event_bit.TEMP_SONG_OVERRIDE), # allow song to change on map change + field.ClearEventBit(npc_bit.MARSHAL_NARSHE_WOB), # Remove Marshal and "Terra" in south caves + field.ClearEventBit(npc_bit.TERRA_COLLAPSED_NARSHE_WOB), # Remove collapsed Terra + + Read(0xcaded, 0xcadf2), # load map + + field.HideEntity(0x1B), # the exit block at top of map + + field.SetParty(1), + field.Call(field.REMOVE_ALL_CHARACTERS_FROM_ALL_PARTIES), + field.LoadRecruitedCharacters(), + ] + for character_idx in range(self.characters.CHARACTER_COUNT): + src += [ + #only restore if character has not been recruited (meaning they were moogled) + field.BranchIfEventBitSet(event_bit.multipurpose(character_idx), f"SKIP_{character_idx}"), + field.RemoveStatusEffects(character_idx, field.Status.FLOAT | field.Status.DARKNESS | field.Status.ZOMBIE | field.Status.POISON | field.Status.VANISH | field.Status.IMP | field.Status.PETRIFY | field.Status.DEATH), + field.RemoveDeath(character_idx), # added due to permadeath situations to make sure the corresponding party member is alive + field.RestoreHp(character_idx, 0x7f), # restore all HP + field.RestoreMp(character_idx, 0x7f), # restore all MP + # Restore character appearance, name, and properties + field.SetSprite(character_idx, self.characters.get_sprite(character_idx)), + field.SetPalette(character_idx, self.characters.get_palette(character_idx)), + field.SetName(character_idx, character_idx), + field.SetEquipmentAndCommands(character_idx, character_idx), + f"SKIP_{character_idx}", + ] + src += [ + # give Shadow Interceptor again + field.AddStatusEffects(self.characters.SHADOW, field.Status.DOG_BLOCK), + + field.Call(field.REFRESH_CHARACTERS_AND_SELECT_PARTY), + field.UpdatePartyLeader(), + field.ShowEntity(field_entity.PARTY0), + field.RefreshEntities(), + + field.FreeScreen(), + + field.FadeInScreen(), + field.WaitForFade(), + + field.SetEventBit(event_bit.FINISHED_MOOGLE_DEFENSE), + field.FreeMovement(), + + # hide Arvis + field.ClearEventBit(npc_bit.ARVIS_INTRO), + field.FinishCheck(), + field.Return(), + ] + space = Reserve(0xcade5, 0xcb04f, "moogle defense victory", field.NOP()) + space.write(src) + + def character_mod(self, character): + sprite = character + self.terra_npc.sprite = sprite + self.terra_npc.palette = self.characters.get_palette(sprite) + self.terra_collapsed_npc.sprite = sprite + self.terra_collapsed_npc.palette = self.characters.get_palette(sprite) + + self.after_battle_mod([ + # Restore character appearance, name, and properties + field.SetSprite(character, self.characters.get_sprite(character)), + field.SetPalette(character, self.characters.get_palette(character)), + field.SetName(character, character), + field.SetEquipmentAndCommands(character, character), + field.RemoveStatusEffects(character, field.Status.FLOAT | field.Status.DARKNESS | field.Status.ZOMBIE | field.Status.POISON | field.Status.VANISH | field.Status.IMP | field.Status.PETRIFY | field.Status.DEATH), + field.RemoveDeath(character), # added due to permadeath situations to make sure the corresponding party member is alive + field.RestoreHp(character, 0x7f), # restore all HP + field.RestoreMp(character, 0x7f), # restore all MP + field.RecruitCharacter(character), + ]) + + def esper_item_mod(self, esper_item_instructions): + if self.args.character_gating: + #Using thematic Moogle sprite for Esper/Items + esper_item_sprite = self.characters.get_sprite(self.characters.MOG) + else: + # Open world -- use standard sprites + esper_item_sprite = self.characters.get_random_esper_item_sprite() + self.terra_npc.sprite = esper_item_sprite + self.terra_npc.palette = self.characters.get_palette(self.terra_npc.sprite) + self.terra_collapsed_npc.sprite = esper_item_sprite + self.terra_collapsed_npc.palette = self.characters.get_palette(self.terra_collapsed_npc.sprite) + + self.after_battle_mod(esper_item_instructions) + + def esper_mod(self, esper): + self.esper_item_mod([ + field.AddEsper(esper), + field.Dialog(self.espers.get_receive_esper_dialog(esper)), + ]) + + def item_mod(self, item): + self.esper_item_mod([ + field.AddItem(item), + field.Dialog(self.items.get_receive_dialog(item)), + ]) + + def mod(self): + self.terra_npc_mod() + + if self.args.debug: + self.marshal_test_mod() + + self.marshal_npc_mod() + + self.arvis_start_mod() + self.event_start_mod() + self.marshal_battle_mod() + + if self.reward.type == RewardType.CHARACTER: + self.character_mod(self.reward.id) + elif self.reward.type == RewardType.ESPER: + self.esper_mod(self.reward.id) + elif self.reward.type == RewardType.ITEM: + self.item_mod(self.reward.id) + + self.log_reward(self.reward) + + + + diff --git a/event/whelk.py b/event/whelk.py index 91966d70..47efc50b 100644 --- a/event/whelk.py +++ b/event/whelk.py @@ -16,9 +16,6 @@ def init_event_bits(self, space): ) def mod(self): - if self.reward.type == RewardType.NONE: - return - self.dialog_mod() self.entrance_event_mod() self.cleanup_mod() diff --git a/graphics/palette_file.py b/graphics/palette_file.py index f8592a1d..da24ce7d 100644 --- a/graphics/palette_file.py +++ b/graphics/palette_file.py @@ -1,4 +1,5 @@ from graphics.palette import Palette +from graphics.bgr15 import BGR15 class PaletteFile(Palette): def __init__(self, path): @@ -6,4 +7,9 @@ def __init__(self, path): super().__init__() with open(path, "rb") as input_file: - self.data = list(input_file.read()) + data = list(input_file.read()) + + if not data or len(data) % BGR15.DATA_SIZE != 0: + raise ValueError(f"PaletteFile: '{path}' size {len(data)} is not a positive multiple of {BGR15.DATA_SIZE} (bgr15 colors)") + + self.data = data diff --git a/graphics/sprite.py b/graphics/sprite.py index d327765a..412e1de0 100644 --- a/graphics/sprite.py +++ b/graphics/sprite.py @@ -50,50 +50,34 @@ def tile_matrix(self, tile_id_matrix): result.append(result_row) return result - def rgb_data(self, pose): + BITS_PER_VALUE = 8 # rgb component size in ppm output + + def _pose_rgb(self, pose): + # render a pose (matrix of tile ids) to (width, height, flat rgb values) pose_values = self.tile_matrix(pose) - OUTPUT_WIDTH = SpriteTile.COL_COUNT * len(pose[0]) - OUTPUT_HEIGHT = SpriteTile.ROW_COUNT * len(pose) + width = SpriteTile.COL_COUNT * len(pose[0]) + height = SpriteTile.ROW_COUNT * len(pose) rgb_values = [] - for row_index in range(OUTPUT_HEIGHT): - for col_index in range(OUTPUT_WIDTH): + for row_index in range(height): + for col_index in range(width): color_id = pose_values[row_index][col_index] rgb_values.extend(self.palette.colors[color_id].rgb) - return rgb_values - - def write_ppm(self, output_file, pose): - import graphics.poses as poses - pose_values = self.tile_matrix(pose) + return width, height, rgb_values - OUTPUT_WIDTH = SpriteTile.COL_COUNT * len(pose[0]) - OUTPUT_HEIGHT = SpriteTile.ROW_COUNT * len(pose) - BITS_PER_VALUE = 8 + def rgb_data(self, pose): + return self._pose_rgb(pose)[2] - rgb_values = [] - for row_index in range(OUTPUT_HEIGHT): - for col_index in range(OUTPUT_WIDTH): - color_id = pose_values[row_index][col_index] - rgb_values.extend(self.palette.colors[color_id].rgb) + def write_ppm(self, output_file, pose): + width, height, rgb_values = self._pose_rgb(pose) from graphics.ppm import write_ppm6 - write_ppm6(OUTPUT_WIDTH, OUTPUT_HEIGHT, BITS_PER_VALUE, rgb_values, output_file) + write_ppm6(width, height, self.BITS_PER_VALUE, rgb_values, output_file) def get_ppm(self, pose): - import graphics.poses as poses - pose_values = self.tile_matrix(pose) - - OUTPUT_WIDTH = SpriteTile.COL_COUNT * len(pose[0]) - OUTPUT_HEIGHT = SpriteTile.ROW_COUNT * len(pose) - BITS_PER_VALUE = 8 - - rgb_values = [] - for row_index in range(OUTPUT_HEIGHT): - for col_index in range(OUTPUT_WIDTH): - color_id = pose_values[row_index][col_index] - rgb_values.extend(self.palette.colors[color_id].rgb) + width, height, rgb_values = self._pose_rgb(pose) from graphics.ppm import get_ppm - return get_ppm(OUTPUT_WIDTH, OUTPUT_HEIGHT, BITS_PER_VALUE, rgb_values) + return get_ppm(width, height, self.BITS_PER_VALUE, rgb_values) diff --git a/graphics/sprite_file.py b/graphics/sprite_file.py index bc931701..514980d3 100644 --- a/graphics/sprite_file.py +++ b/graphics/sprite_file.py @@ -7,4 +7,9 @@ def __init__(self, path, palette): super().__init__([], palette) with open(path, "rb") as input_file: - self.data = list(input_file.read()) + data = list(input_file.read()) + + if not data or len(data) % SpriteTile.DATA_SIZE != 0: + raise ValueError(f"SpriteFile: '{path}' size {len(data)} is not a positive multiple of {SpriteTile.DATA_SIZE} (8x8 tiles)") + + self.data = data diff --git a/graphics/tools/png_portrait.py b/graphics/tools/png_portrait.py index 288b79f7..4abb8ca1 100644 --- a/graphics/tools/png_portrait.py +++ b/graphics/tools/png_portrait.py @@ -57,7 +57,11 @@ def write_sprite(output_prefix, sprite, tile_indices): output.write(bytes(sprite.data)) def convert(image_path): - from PIL import Image + try: + from PIL import Image + except ImportError: + raise ImportError("this developer tool requires the Pillow library (pip install Pillow); " + "the randomizer itself does not need it") from None image = Image.open(image_path) import os diff --git a/graphics/tools/png_sprite.py b/graphics/tools/png_sprite.py index 5c807b6a..e14ab1fb 100644 --- a/graphics/tools/png_sprite.py +++ b/graphics/tools/png_sprite.py @@ -98,7 +98,11 @@ def write_sprite(output_prefix, sprite, tile_indices): output.write(bytes(sprite.data)) def convert(image_path): - from PIL import Image + try: + from PIL import Image + except ImportError: + raise ImportError("this developer tool requires the Pillow library (pip install Pillow); " + "the randomizer itself does not need it") from None image = Image.open(image_path) import os diff --git a/instruction/c3.py b/instruction/c3.py index 0e10ecc0..80e33704 100644 --- a/instruction/c3.py +++ b/instruction/c3.py @@ -1,24 +1,24 @@ -from memory.space import Bank, START_ADDRESS_SNES, Space, Reserve, Allocate, Free, Write -import instruction.asm as asm - -# Allow Eggers jumps into C3 -- that is, enable calls to JSR routines from other banks -# Ref: https://www.ff6hacking.com/forums/thread-2301.html -def _eggers_jump_return_mod(): - src = [ - asm.RTS(), - asm.RTL() - ] - space = Write(Bank.C3, src, "C3 eggers jump return") - return space.start_address -eggers_jump_return = _eggers_jump_return_mod() - -# Eggers jump src to jump to the specified C3 subroutine and successfully return to another bank -def eggers_jump(c3addr): - src = [ - asm.PHK(), - asm.PER(0x0009), - asm.PEA(eggers_jump_return), - asm.PEA(c3addr-1), # return after execution - asm.JMP(eggers_jump_return + START_ADDRESS_SNES, asm.LNG), - ] +from memory.space import Bank, START_ADDRESS_SNES, Space, Reserve, Allocate, Free, Write +import instruction.asm as asm + +# Allow Eggers jumps into C3 -- that is, enable calls to JSR routines from other banks +# Ref: https://www.ff6hacking.com/forums/thread-2301.html +def _eggers_jump_return_mod(): + src = [ + asm.RTS(), + asm.RTL() + ] + space = Write(Bank.C3, src, "C3 eggers jump return") + return space.start_address +eggers_jump_return = _eggers_jump_return_mod() + +# Eggers jump src to jump to the specified C3 subroutine and successfully return to another bank +def eggers_jump(c3addr): + src = [ + asm.PHK(), + asm.PER(0x0009), + asm.PEA(eggers_jump_return), + asm.PEA(c3addr-1), # return after execution + asm.JMP(eggers_jump_return + START_ADDRESS_SNES, asm.LNG), + ] return src \ No newline at end of file diff --git a/instruction/field/instructions.py b/instruction/field/instructions.py index 0b8f2560..a8b33be2 100644 --- a/instruction/field/instructions.py +++ b/instruction/field/instructions.py @@ -626,7 +626,8 @@ def __str__(self): class SetEventBit(_Instruction): def __init__(self, event_bit): self.event_bit = event_bit - assert self.event_bit <= 0x6ff + if not 0 <= self.event_bit <= 0x6ff: + raise ValueError(f"SetEventBit: invalid event bit {hex(self.event_bit)}, must be within 0x000-0x6ff") opcode = 0xd0 + (self.event_bit // 0x100) * 2 arg = self.event_bit & 0xff @@ -638,7 +639,8 @@ def __str__(self): class ClearEventBit(_Instruction): def __init__(self, event_bit): self.event_bit = event_bit - assert self.event_bit <= 0x6ff + if not 0 <= self.event_bit <= 0x6ff: + raise ValueError(f"ClearEventBit: invalid event bit {hex(self.event_bit)}, must be within 0x000-0x6ff") opcode = 0xd1 + (self.event_bit // 0x100) * 2 arg = self.event_bit & 0xff diff --git a/llms.md b/llms.md index c03eea55..24ab193f 100644 --- a/llms.md +++ b/llms.md @@ -183,6 +183,8 @@ All generated test data, output ROMs, log text files, and metadata manifests gen - Output test ROM: `tests/test_output.smc` - Output test Log: `tests/test_output.txt` - Manifest file: `tests/test_manifest.json` +- **Unit Tests**: `tests/` also contains the committed unit test suite (`tests/test_*.py`) covering ROM-independent logic (memory allocation, label/branch encoding, compression, seeding, CLI parsing). Run it with `python3 -m unittest discover -s tests -v`; it requires no ROM file. CI (`.github/workflows/ci.yml`) runs it on every push/PR, plus a guard that rejects committed ROM-like files. Generated artifacts in `tests/` are gitignored (`*.smc`, `*.sfc`, `tests/*.txt`, `tests/*.json`); test source files are tracked. +- **Memory Overflow Errors**: Space overflows raise `memory.errors.RomSpaceError` (a `MemoryError` subclass). Bounds are validated *before* writing, so an overflowing `space.write(...)` never corrupts adjacent ROM bytes. --- diff --git a/log/__init__.py b/log/__init__.py index d8431cd0..be8f85bb 100644 --- a/log/__init__.py +++ b/log/__init__.py @@ -1,3 +1,12 @@ +"""Spoiler log setup. Importing this module has side effects: + +it creates the log file next to the output rom (or configures stdout +logging with -slog), writes the header and flag sections, and writes the +api manifest if -manifest was given. It must therefore only be imported +after `args` is fully usable — in practice wc.py imports it first thing. +Subsequent modules log by calling logging.info() or the format helpers +from log.format. +""" import logging, os from log.format import * @@ -8,7 +17,8 @@ import sys logging.basicConfig(stream = sys.stdout, filemode = 'w', level = logging.INFO, format = "%(message)s") else: - logging.basicConfig(filename = log_file, filemode = 'w', level = logging.INFO, format = "%(message)s") + # explicit utf-8 so log content is identical across platforms (notably Windows) + logging.basicConfig(filename = log_file, filemode = 'w', level = logging.INFO, format = "%(message)s", encoding = "utf-8") hash = ', '.join([entry.name for entry in args.sprite_hash]) import time diff --git a/memory/errors.py b/memory/errors.py new file mode 100644 index 00000000..c98b0515 --- /dev/null +++ b/memory/errors.py @@ -0,0 +1,8 @@ +# RomSpaceError subclasses MemoryError because these failures were historically +# raised as MemoryError; existing callers/tools catching MemoryError still work +class RomSpaceError(MemoryError): + """Raised when data does not fit in the requested/reserved ROM space. + + Common causes and resolutions are documented in agents.md under + "Memory Overflow / Bank Exhaustion". + """ diff --git a/memory/heap.py b/memory/heap.py index 55460e52..75d3e409 100644 --- a/memory/heap.py +++ b/memory/heap.py @@ -1,5 +1,7 @@ +from memory.errors import RomSpaceError + class Block: - def __init__(self, start, end): + def __init__(self, start: int, end: int): if start > end: self._start = end self._end = start @@ -35,7 +37,7 @@ def __init__(self): self.blocks = [] self._available = 0 - def allocate(self, size): + def allocate(self, size: int) -> int: def find_best_fit(size): best_block = None if not self.blocks: @@ -53,7 +55,7 @@ def find_best_fit(size): block = find_best_fit(size) if block is None: - raise MemoryError(f"Unable to allocate block of size {size}") + raise RomSpaceError(f"Unable to allocate block of size {size}") start = block.start block.start += size @@ -62,7 +64,7 @@ def find_best_fit(size): self._available -= size return start - def free(self, start, end): + def free(self, start: int, end: int) -> None: new_block = Block(start, end) overlaps = set() @@ -86,7 +88,7 @@ def free(self, start, end): self._available += block.size self.blocks = new_blocks - def reserve(self, start, end): + def reserve(self, start: int, end: int) -> None: reserved = Block(start, end) overlaps = set() diff --git a/memory/label.py b/memory/label.py index 6b65d926..5685c725 100644 --- a/memory/label.py +++ b/memory/label.py @@ -1,5 +1,5 @@ class Label: - def __init__(self, name): + def __init__(self, name: str): self.name = name self.address = None @@ -15,7 +15,7 @@ def __init__(self, label, address, mode): self.address = address # address of the pointer itself self.mode = mode # absolute, relative, branch_relative - def __int__(self): + def __int__(self) -> int: value = self.label.address + self.offset if self.mode == self.RELATIVE: return value - self.address @@ -23,15 +23,15 @@ def __int__(self): return abs(value - self.address) elif self.mode == self.BRANCH_RELATIVE: value -= self.address - if value > 127 or value < -128: - raise ValueError(f"Error on Branch to label {self.label.name}. Branch distance: {value-1}") - if value > 0: - return value - 1 - elif value < 0: - return value + 0xff + # branch offsets are relative to the pc after the one byte operand, + # so the encoded offset is (distance - 1) in two's complement and + # the reachable distance range is [-127, 128] + if value > 128 or value < -127: + raise ValueError(f"Error on Branch to label {self.label.name}. Branch distance: {value} not in [-127, 128]") + return (value - 1) % 256 return value - def to_bytes(self, length, byteorder, *, signed = False): + def to_bytes(self, length: int, byteorder: str, *, signed: bool = False) -> bytes: return int(self).to_bytes(length, byteorder, signed = signed) def __index__(self): diff --git a/memory/space.py b/memory/space.py index 5cc6c945..096ba777 100644 --- a/memory/space.py +++ b/memory/space.py @@ -1,6 +1,7 @@ from memory.rom import ROM from memory.heap import Heap from memory.label import Label, LabelPointer +from memory.errors import RomSpaceError from enum import IntEnum BANK_SIZE = 0x10000 @@ -9,11 +10,23 @@ START_ADDRESS_SNES = 0xc00000 class Space(): + """A contiguous range of rom addresses being written with code/data. + + Construct via the module level helpers: Reserve() for fixed vanilla + address ranges, Allocate() for dynamic placement in a bank's free + space (made available with Free()), or Write() to allocate and write + in one call. + + Class-level shared state: `Space.rom` is the single rom buffer + (assigned by memory.memory.Memory() at startup), `Space.heaps` tracks + each bank's free space, and `Space.spaces` is the sorted list of all + spaces created so far, used to detect overlapping reservations. + """ rom = None heaps = { bank : Heap() for bank in Bank } spaces = [] - def __init__(self, start_address, end_address, description, clear_value = None): + def __init__(self, start_address: int, end_address: int, description: str, clear_value = None): self._start_address = start_address self._end_address = end_address self._next_address = self.start_address @@ -74,34 +87,39 @@ def end_address_snes(self): def description(self): return self._description - def write(self, *values): + def write(self, *values) -> None: from utils.flatten import flatten values = flatten(values) values = self._invoke_callables(values) values = self._parse_labels(values) - self._next_address = Space.rom.set_bytes(self.next_address, values) - if(self.next_address - 1 > self.end_address): - raise MemoryError(f"Not enough room in space \"{self.description}\": Next (0x{self.next_address -1:x}) > End (0x{self.end_address:x}). Diff: {(self.next_address - 1) - (self.end_address)}") + # validate bounds before writing so an overflow cannot corrupt bytes + # beyond the end of this space + last_address = self.next_address + len(values) - 1 + if last_address > self.end_address: + raise RomSpaceError(f"Not enough room in space \"{self.description}\": Next (0x{last_address:x}) > End (0x{self.end_address:x}). Diff: {last_address - self.end_address}") + self._next_address = Space.rom.set_bytes(self.next_address, values) self._update_label_pointers() def clear(self, value): try: values = [value] * (len(self) // len(value)) - except: + except TypeError: + # value is a single int (no len()), not a sequence values = [value] * len(self) values = self._invoke_callables(values) - assert len(self) == len(values) # do values evenly fill space? + if len(self) != len(values): # do values evenly fill space? + raise ValueError(f"clear: {len(values)} values do not evenly fill space {str(self)} ({len(self)} bytes)") Space.rom.set_bytes(self.start_address, values) self._next_address = self.start_address - def copy_from(self, start_address, end_address): + def copy_from(self, start_address: int, end_address: int) -> None: self.write(Space.rom.get_bytes(start_address, end_address - start_address + 1)) - def add_label(self, name, address): + def add_label(self, name: str, address: int) -> None: self.labels[name] = Label(name) self.labels[name].address = address @@ -129,6 +147,11 @@ def _label_pointer(self, name, mode): return label_pointer # return a new pointer to a new label def _invoke_callables(self, values): + # expand instruction objects into their bytes. instructions (see + # instruction/asm.py) are callables: calling one with this space + # resolves it to a flat byte list (possibly containing LabelPointer + # placeholders). each instruction is also recorded by address in + # self.instructions so __repr__ can disassemble the space from utils.flatten import flatten result = [] index = 0 @@ -146,6 +169,15 @@ def _invoke_callables(self, values): def _parse_labels(self, values): # find labels (strs) in given values list and update the addresses of the labels and the label pointers + # + # labels support forward references in two passes: + # 1. here: a str value defines a label at the current address (and is + # not written). a LabelPointer to a label already defined in this + # space is resolved to bytes immediately; one not yet defined is + # written as None placeholder bytes (16/24-bit) or left as the + # LabelPointer object itself (8-bit, resolved lazily via __index__) + # 2. _update_label_pointers() (called after every write) overwrites + # the placeholders once the target label has been defined index = 0 new_values = [] for value in values: @@ -177,7 +209,8 @@ def _parse_labels(self, values): new_values.append(value) try: index += len(value) - except: + except TypeError: + # value is a single byte/int (no len()) index += 1 return new_values @@ -261,26 +294,26 @@ def print(self): def printr(self): print(repr(self)) -def Reserve(start_address, end_address, description, clear_value = None): +def Reserve(start_address: int, end_address: int, description: str, clear_value = None) -> Space: bank_start = (start_address // BANK_SIZE) * BANK_SIZE heap = Space.heaps[Bank(bank_start)] heap.reserve(start_address, end_address) return Space(start_address, end_address, description, clear_value) -def Allocate(bank, size, description, clear_value = None): +def Allocate(bank: Bank, size: int, description: str, clear_value = None) -> Space: heap = Space.heaps[bank] start_address = heap.allocate(size) end_address = start_address + size - 1 return Space(start_address, end_address, description, clear_value) -def Free(start_address, end_address): +def Free(start_address: int, end_address: int) -> None: bank_start = (start_address // BANK_SIZE) * BANK_SIZE heap = Space.heaps[Bank(bank_start)] heap.free(start_address, end_address) -def Write(destination, data, description): +def Write(destination, data, description: str) -> Space: from utils.flatten import flatten size = 0 @@ -299,5 +332,5 @@ def Write(destination, data, description): space.write(data) return space -def Read(start_address, end_address): +def Read(start_address: int, end_address: int) -> list: return Space.rom.get_bytes(start_address, end_address - start_address + 1) diff --git a/menus/flags_remove_learnable_spells.py b/menus/flags_remove_learnable_spells.py index 8bb4eb21..0760df73 100644 --- a/menus/flags_remove_learnable_spells.py +++ b/menus/flags_remove_learnable_spells.py @@ -1,55 +1,55 @@ -import menus.pregame_track_scroll_area as scroll_area -from data.text.text2 import text_value -import instruction.f0 as f0 - -class FlagsRemoveLearnableSpells(scroll_area.ScrollArea): - MENU_NUMBER = 15 - - def __init__(self, spell_ids): - self.number_items = len(spell_ids) - self.lines = [] - - self.lines.append(scroll_area.Line(f"Remove Learnable Spells", f0.set_blue_text_color)) - - spell_lines = FlagsRemoveLearnableSpells._format_spells_menu(spell_ids) - - for list_value in spell_lines: - padding = scroll_area.WIDTH - (len(list_value)) - self.lines.append(scroll_area.Line(f"{' ' * padding}{list_value}", f0.set_user_text_color)) - - super().__init__() - - def _format_spells_menu(spell_ids): - from constants.spells import id_spell - COLUMN_WIDTHS = [8, 8, 8] - spell_lines = [] - - # Step through each spell by the number of columns - for spell_idx in range(0, len(spell_ids), len(COLUMN_WIDTHS)): - current_line = '' - # Populate each column on the line - for col in range(0, len(COLUMN_WIDTHS)): - if(spell_idx + col < len(spell_ids)): - a_spell_id = spell_ids[spell_idx + col] - icon = FlagsRemoveLearnableSpells._get_spell_icon(a_spell_id) - spell_str = f"{icon}{id_spell[a_spell_id]}" - padding = COLUMN_WIDTHS[col] - len(spell_str) - current_line += f"{spell_str}{' ' * padding}" - else: - # No spell, add padding - current_line += f"{' ' * COLUMN_WIDTHS[col]}" - # Write the line - spell_lines.append(current_line) - return spell_lines - - def _get_spell_icon(spell_id): - from constants.spells import black_magic_ids, gray_magic_ids, white_magic_ids - from data.text.text2 import text_value - icon = '' - if spell_id in black_magic_ids: - icon = chr(text_value['']) - elif spell_id in gray_magic_ids: - icon = chr(text_value['']) - elif spell_id in white_magic_ids: - icon = chr(text_value['']) - return icon +import menus.pregame_track_scroll_area as scroll_area +from data.text.text2 import text_value +import instruction.f0 as f0 + +class FlagsRemoveLearnableSpells(scroll_area.ScrollArea): + MENU_NUMBER = 15 + + def __init__(self, spell_ids): + self.number_items = len(spell_ids) + self.lines = [] + + self.lines.append(scroll_area.Line(f"Remove Learnable Spells", f0.set_blue_text_color)) + + spell_lines = FlagsRemoveLearnableSpells._format_spells_menu(spell_ids) + + for list_value in spell_lines: + padding = scroll_area.WIDTH - (len(list_value)) + self.lines.append(scroll_area.Line(f"{' ' * padding}{list_value}", f0.set_user_text_color)) + + super().__init__() + + def _format_spells_menu(spell_ids): + from constants.spells import id_spell + COLUMN_WIDTHS = [8, 8, 8] + spell_lines = [] + + # Step through each spell by the number of columns + for spell_idx in range(0, len(spell_ids), len(COLUMN_WIDTHS)): + current_line = '' + # Populate each column on the line + for col in range(0, len(COLUMN_WIDTHS)): + if(spell_idx + col < len(spell_ids)): + a_spell_id = spell_ids[spell_idx + col] + icon = FlagsRemoveLearnableSpells._get_spell_icon(a_spell_id) + spell_str = f"{icon}{id_spell[a_spell_id]}" + padding = COLUMN_WIDTHS[col] - len(spell_str) + current_line += f"{spell_str}{' ' * padding}" + else: + # No spell, add padding + current_line += f"{' ' * COLUMN_WIDTHS[col]}" + # Write the line + spell_lines.append(current_line) + return spell_lines + + def _get_spell_icon(spell_id): + from constants.spells import black_magic_ids, gray_magic_ids, white_magic_ids + from data.text.text2 import text_value + icon = '' + if spell_id in black_magic_ids: + icon = chr(text_value['']) + elif spell_id in gray_magic_ids: + icon = chr(text_value['']) + elif spell_id in white_magic_ids: + icon = chr(text_value['']) + return icon diff --git a/menus/magic.py b/menus/magic.py index af5e1ff9..a0ecf254 100644 --- a/menus/magic.py +++ b/menus/magic.py @@ -1,42 +1,42 @@ -from memory.space import Write, Bank, Reserve -import instruction.asm as asm -import args - -class MagicMenu: - def __init__(self): - self.mod() - - def draw_three_digits(self): - # Enable drawing of 3 digits - # Create string function - STRING_DRAW_ADDR = 0x2180 # Where to write strings to be written - src = [ - asm.LDA(0xF7, asm.DIR), # Hundreds digit - asm.STA(STRING_DRAW_ADDR, asm.ABS), # Add to string - # displaced vanilla logic, from C3/51E9 - 51ED - asm.LDA(0xF8, asm.DIR), # Tens digit - asm.STA(STRING_DRAW_ADDR, asm.ABS), # Add to string - asm.RTL() - ] - space = Write(Bank.F0, src, "Create MP Cost string") - create_string = space.start_address_snes - - space = Reserve(0x351e9, 0x351ed, "Call create_string", asm.NOP()) - space.write( - asm.JSL(create_string), - ) - - # Move where MP gets written 1 space to the left, - # to avoid having the number show up at the top of the "Espers" menu - space = Reserve(0x351cd, 0x351cd, "MP String location") - space.write(0xbd) #original: 0xbf (each text space is a value of 2) - - def fix_in_battle_mp_tens_digit(self): - # Fix Vanilla in-battle MP listing in which the ten's digit is blanked - # if it is 0 but the hundreds digit is not - space = Reserve(0x1057b, 0x1057b, "MP Hundreds non-zero BNE offset") - space.write(0x14) # original: 0x08; 0x14 causes it to jump to RTS if the hundreds place is non-zero - - def mod(self): - self.draw_three_digits() - self.fix_in_battle_mp_tens_digit() +from memory.space import Write, Bank, Reserve +import instruction.asm as asm +import args + +class MagicMenu: + def __init__(self): + self.mod() + + def draw_three_digits(self): + # Enable drawing of 3 digits + # Create string function + STRING_DRAW_ADDR = 0x2180 # Where to write strings to be written + src = [ + asm.LDA(0xF7, asm.DIR), # Hundreds digit + asm.STA(STRING_DRAW_ADDR, asm.ABS), # Add to string + # displaced vanilla logic, from C3/51E9 - 51ED + asm.LDA(0xF8, asm.DIR), # Tens digit + asm.STA(STRING_DRAW_ADDR, asm.ABS), # Add to string + asm.RTL() + ] + space = Write(Bank.F0, src, "Create MP Cost string") + create_string = space.start_address_snes + + space = Reserve(0x351e9, 0x351ed, "Call create_string", asm.NOP()) + space.write( + asm.JSL(create_string), + ) + + # Move where MP gets written 1 space to the left, + # to avoid having the number show up at the top of the "Espers" menu + space = Reserve(0x351cd, 0x351cd, "MP String location") + space.write(0xbd) #original: 0xbf (each text space is a value of 2) + + def fix_in_battle_mp_tens_digit(self): + # Fix Vanilla in-battle MP listing in which the ten's digit is blanked + # if it is 0 but the hundreds digit is not + space = Reserve(0x1057b, 0x1057b, "MP Hundreds non-zero BNE offset") + space.write(0x14) # original: 0x08; 0x14 causes it to jump to RTS if the hundreds place is non-zero + + def mod(self): + self.draw_three_digits() + self.fix_in_battle_mp_tens_digit() diff --git a/menus/rage.py b/menus/rage.py index 71a51dce..7bf7d8b1 100644 --- a/menus/rage.py +++ b/menus/rage.py @@ -1,138 +1,138 @@ -from memory.space import Bank, Reserve, Allocate, Write -import instruction.asm as asm -from data.spell_names import name_id, id_name - - -class RageMenu: - def __init__(self, rages, enemies): - self.rages = rages - self.enemies = enemies - - # Build an array to lookup enemy-specific Special command effects - from constants.status_effects import A, B, C, D - self.status_effects = [] - self.status_effects.extend(list(A.id_name.values())) - self.status_effects.extend(list(B.id_name.values())) - self.status_effects.extend(list(C.id_name.values())) - self.status_effects.extend(list(D.id_name.values())) - - # Remove death from the status effects list, as it requires a second bit from flags1 - self.status_effects = list(map(lambda x: x.replace("Death", ""), self.status_effects)) - - # Remove other statuses that aren't really relevant - self.status_effects = list(map(lambda x: x.replace("Near Fatal", ""), self.status_effects)) - self.status_effects = list(map(lambda x: x.replace("Hide", ""), self.status_effects)) - - self.special_effects = [] - self.special_effects.extend(self.status_effects) - for i in range(15, 95, 5): # going from 1.5x - 9.0x damage - dmg_multiplier = i / 10 - self.special_effects.append(f"{dmg_multiplier:.1f}x dmg") - self.special_effects.append("Drain HP") - self.special_effects.append("Drain MP") - self.special_effects.append("Remove Reflect") - - self.mod() - - def get_rage_string(self, id, attack_id): - from data.spell_names import id_name, name_id - - if attack_id == name_id["Special"]: - # handle special name lookup + special attack info (dmg multipler, status effect) - enemy = self.enemies.enemies[id] - special_name = enemy.special_name - special_effect = enemy.special_effect - - rage_str = f"{special_name}: " - rage_str += self.special_effects[special_effect] - else: - rage_str = f"{id_name[attack_id]}" - - # # remove duplicate white spaces - import re - rage_str = re.sub(' +', ' ', rage_str) - - # remove leading and trailing spaces - rage_str = rage_str.strip() - return rage_str - - def draw_ability_names_mod(self): - import data.text as text - - # Get the custom strings for each rage to be written to the ROM - lines = [] - for rage in self.rages.rages: - # Only focusing on attack2, as attack1 is simply "Battle" -- if that changes in the future, this string can be revisited - rage_str = f"{self.get_rage_string(rage.id, rage.attack2)}" - lines.append(rage_str) - - line_offsets = [0] - running_offset = 0 - # Write the lines to F0 - src = [] - for line in lines: - # convert to bytes - bytes = text.get_bytes(line, text.TEXT3) - running_offset += len(bytes) - line_offsets.append(running_offset) - src.append(bytes) - space = Write(Bank.F0, src, "rage description lines table") - lines_table = space.start_address_snes - - # write the 2-byte line offsets to F0 - src = [] - for offset in line_offsets: - src.append(offset.to_bytes(2, 'little')) - space = Write(Bank.F0, src, "rage description lines table offsets") - lines_table_offsets = space.start_address_snes - - src = [ - asm.LDX(0x9ec9, asm.IMM16), # dest WRAM LBs - asm.STX(0x2181, asm.ABS), # store dest WRAM LBs - - asm.TDC(), # a = 0x0000 - asm.LDA(0x4b, asm.DIR), # a = cursor index (rage index) - asm.TAX(), # x = cursor index (rage index) - asm.LDA(0x7e9d89, asm.LNG_X), # a = rage at cursor index - asm.CMP(0xff, asm.IMM8), # compare with no rage - asm.BEQ("END_STRING_RETURN"), # branch if rage at cursor index not learned - asm.A16(), - asm.ASL(), # a = rage index * 2 (2 bytes per table offset) - asm.TAX(), # x = rage index * 2 - asm.LDA(lines_table_offsets, asm.LNG_X), # get the offset - asm.TAX(), - asm.A8(), - "STRING_LOOP_START", - asm.LDA(lines_table, asm.LNG_X), # get the character - asm.STA(0x2180, asm.ABS), # add character to string - asm.CMP(0x00, asm.IMM8), # was it the end of the string? - asm.BEQ("RETURN"), # if so, be done - asm.INX(), # move to next character in ability name - asm.BRA("STRING_LOOP_START"), - "END_STRING_RETURN", - asm.STZ(0x2180, asm.ABS), # end string - "RETURN", - asm.RTS(), - ] - space = Write(Bank.C3, src, "draw ability names") - draw_ability_names = space.start_address - - sustain_replace = 0x328c6 # handle L and R - replace_size = 3 # replacing jsr instructions - - src = [ - asm.LDA(0x10, asm.IMM8),# enable description menu flag bitmask - asm.TRB(0x45, asm.DIR), # enable descriptions - asm.JSR(0x4c52, asm.ABS), # displaced code: handle D-Pad - asm.JMP(draw_ability_names, asm.ABS), - ] - space = Write(Bank.C3, src, "sustain rage list") - sustain_rage_list = space.start_address - - space = Reserve(sustain_replace, sustain_replace + replace_size - 1, "rage menu sustain handle D-Pad") - space.write( - asm.JSR(sustain_rage_list, asm.ABS), - ) - - def mod(self): +from memory.space import Bank, Reserve, Allocate, Write +import instruction.asm as asm +from data.spell_names import name_id, id_name + + +class RageMenu: + def __init__(self, rages, enemies): + self.rages = rages + self.enemies = enemies + + # Build an array to lookup enemy-specific Special command effects + from constants.status_effects import A, B, C, D + self.status_effects = [] + self.status_effects.extend(list(A.id_name.values())) + self.status_effects.extend(list(B.id_name.values())) + self.status_effects.extend(list(C.id_name.values())) + self.status_effects.extend(list(D.id_name.values())) + + # Remove death from the status effects list, as it requires a second bit from flags1 + self.status_effects = list(map(lambda x: x.replace("Death", ""), self.status_effects)) + + # Remove other statuses that aren't really relevant + self.status_effects = list(map(lambda x: x.replace("Near Fatal", ""), self.status_effects)) + self.status_effects = list(map(lambda x: x.replace("Hide", ""), self.status_effects)) + + self.special_effects = [] + self.special_effects.extend(self.status_effects) + for i in range(15, 95, 5): # going from 1.5x - 9.0x damage + dmg_multiplier = i / 10 + self.special_effects.append(f"{dmg_multiplier:.1f}x dmg") + self.special_effects.append("Drain HP") + self.special_effects.append("Drain MP") + self.special_effects.append("Remove Reflect") + + self.mod() + + def get_rage_string(self, id, attack_id): + from data.spell_names import id_name, name_id + + if attack_id == name_id["Special"]: + # handle special name lookup + special attack info (dmg multipler, status effect) + enemy = self.enemies.enemies[id] + special_name = enemy.special_name + special_effect = enemy.special_effect + + rage_str = f"{special_name}: " + rage_str += self.special_effects[special_effect] + else: + rage_str = f"{id_name[attack_id]}" + + # # remove duplicate white spaces + import re + rage_str = re.sub(' +', ' ', rage_str) + + # remove leading and trailing spaces + rage_str = rage_str.strip() + return rage_str + + def draw_ability_names_mod(self): + import data.text as text + + # Get the custom strings for each rage to be written to the ROM + lines = [] + for rage in self.rages.rages: + # Only focusing on attack2, as attack1 is simply "Battle" -- if that changes in the future, this string can be revisited + rage_str = f"{self.get_rage_string(rage.id, rage.attack2)}" + lines.append(rage_str) + + line_offsets = [0] + running_offset = 0 + # Write the lines to F0 + src = [] + for line in lines: + # convert to bytes + bytes = text.get_bytes(line, text.TEXT3) + running_offset += len(bytes) + line_offsets.append(running_offset) + src.append(bytes) + space = Write(Bank.F0, src, "rage description lines table") + lines_table = space.start_address_snes + + # write the 2-byte line offsets to F0 + src = [] + for offset in line_offsets: + src.append(offset.to_bytes(2, 'little')) + space = Write(Bank.F0, src, "rage description lines table offsets") + lines_table_offsets = space.start_address_snes + + src = [ + asm.LDX(0x9ec9, asm.IMM16), # dest WRAM LBs + asm.STX(0x2181, asm.ABS), # store dest WRAM LBs + + asm.TDC(), # a = 0x0000 + asm.LDA(0x4b, asm.DIR), # a = cursor index (rage index) + asm.TAX(), # x = cursor index (rage index) + asm.LDA(0x7e9d89, asm.LNG_X), # a = rage at cursor index + asm.CMP(0xff, asm.IMM8), # compare with no rage + asm.BEQ("END_STRING_RETURN"), # branch if rage at cursor index not learned + asm.A16(), + asm.ASL(), # a = rage index * 2 (2 bytes per table offset) + asm.TAX(), # x = rage index * 2 + asm.LDA(lines_table_offsets, asm.LNG_X), # get the offset + asm.TAX(), + asm.A8(), + "STRING_LOOP_START", + asm.LDA(lines_table, asm.LNG_X), # get the character + asm.STA(0x2180, asm.ABS), # add character to string + asm.CMP(0x00, asm.IMM8), # was it the end of the string? + asm.BEQ("RETURN"), # if so, be done + asm.INX(), # move to next character in ability name + asm.BRA("STRING_LOOP_START"), + "END_STRING_RETURN", + asm.STZ(0x2180, asm.ABS), # end string + "RETURN", + asm.RTS(), + ] + space = Write(Bank.C3, src, "draw ability names") + draw_ability_names = space.start_address + + sustain_replace = 0x328c6 # handle L and R + replace_size = 3 # replacing jsr instructions + + src = [ + asm.LDA(0x10, asm.IMM8),# enable description menu flag bitmask + asm.TRB(0x45, asm.DIR), # enable descriptions + asm.JSR(0x4c52, asm.ABS), # displaced code: handle D-Pad + asm.JMP(draw_ability_names, asm.ABS), + ] + space = Write(Bank.C3, src, "sustain rage list") + sustain_rage_list = space.start_address + + space = Reserve(sustain_replace, sustain_replace + replace_size - 1, "rage menu sustain handle D-Pad") + space.write( + asm.JSR(sustain_rage_list, asm.ABS), + ) + + def mod(self): self.draw_ability_names_mod() \ No newline at end of file diff --git a/metadata/flag_metadata_writer.py b/metadata/flag_metadata_writer.py index c8337299..ab1712c9 100644 --- a/metadata/flag_metadata_writer.py +++ b/metadata/flag_metadata_writer.py @@ -57,8 +57,11 @@ def get_flag_metadata(self): self.metadata[key].args = action.metavar if action.choices is not None and isinstance(action.choices, list) and not isinstance(action.choices, range): self.metadata[key].allowed_values = list(action.choices) - if type(group_title): - self.metadata[key].group = group_title if type(group_title) == str else None if group_title == None else group_title() + # group titles may be a plain string, None, or a callable returning the name + if isinstance(group_title, str) or group_title is None: + self.metadata[key].group = group_title + else: + self.metadata[key].group = group_title() if getattr(action, 'mutually_exclusive_group_title', None) is not None: self.metadata[key].mutually_exclusive_group = action.mutually_exclusive_group_title if getattr(action, 'choices', None) is not None: diff --git a/objectives/__init__.py b/objectives/__init__.py index b74198db..3f7e4d92 100644 --- a/objectives/__init__.py +++ b/objectives/__init__.py @@ -1,3 +1,17 @@ +"""On import this package replaces itself with an Objectives instance. + +`import objectives` therefore yields an object, not a module: + + import objectives + for objective in objectives: # iterate Objective instances + ... + objectives.results["Add Boss Levels"] # objectives grouped by result name + +The instance is built from args.objectives, so this import (like most of +the codebase) requires `args` to be importable. See objectives/objectives.py +for the class definition and llms.md for how to add new objective +conditions/results. +""" import sys module = sys.modules[__name__] diff --git a/objectives/conditions/_asm_condition.py b/objectives/conditions/_asm_condition.py new file mode 100644 index 00000000..9d8291fc --- /dev/null +++ b/objectives/conditions/_asm_condition.py @@ -0,0 +1,80 @@ +# shared implementation of the asm-based objective conditions +# (see _battle_condition.py and _menu_condition.py for the public variants) +# +# the battle and menu variants must remain distinct classes even though the +# generated code is identical: _CachedFunction caches written routines per +# class, and each variant must write its own copy of a routine. module +# subclasses only set `condition_type`, used in the space descriptions + +from memory.space import Bank, Write +import instruction.asm as asm +from objectives._cached_function import _CachedFunction + +import data.event_bit as event_bit +import data.battle_bit as battle_bit +import data.event_word as event_word + +class _Condition(_CachedFunction, asm.JSR): + condition_type = None # "battle" or "menu", set by module subclasses + + def __init__(self, *args, **kwargs): + _CachedFunction.__init__(self, *args, **kwargs) + asm.JSR.__init__(self, self.address(*args, **kwargs), asm.ABS) + +class _BitCondition(_Condition): + def write(self, address, bit, true, false): + if true is None: + true = [] + if false is None: + false = [] + + src = [ + asm.LDA(address, asm.ABS), + asm.AND(2 ** bit, asm.IMM8), + asm.BEQ("FALSE"), + + true, + asm.RTS(), + + "FALSE", + false, + asm.RTS(), + ] + return Write(Bank.F0, src, f"{self.condition_type} bit condition {hex(address)} {hex(bit)}") + +class _EventBitCondition(_BitCondition): + def write(self, bit, true = None, false = None): + return super().write(event_bit.address(bit), event_bit.bit(bit), true, false) + +class _BattleBitCondition(_BitCondition): + def write(self, bit, true = None, false = None): + return super().write(battle_bit.address(bit), battle_bit.bit(bit), true, false) + +class _CharacterCondition(_BitCondition): + def write(self, character, true = None, false = None): + return super().write(event_bit.address(event_bit.character_recruited(character)), character % 8, true, false) + +class _EsperCondition(_BitCondition): + def write(self, esper, true = None, false = None): + return super().write(0x1a69 + esper // 8, esper % 8, true, false) + +class _EventWordCondition(_Condition): + def write(self, word, count, ge = None, lt = None): + if ge is None: + ge = [] + if lt is None: + lt = [] + + src = [ + asm.LDA(event_word.address(word), asm.ABS), + asm.CMP(count, asm.IMM8), + asm.BLT("LT"), + + ge, + asm.RTS(), + + "LT", + lt, + asm.RTS(), + ] + return Write(Bank.F0, src, f"{self.condition_type} word condition {hex(word)} {count}") diff --git a/objectives/conditions/_battle_condition.py b/objectives/conditions/_battle_condition.py index 7588d3b8..65f06a72 100644 --- a/objectives/conditions/_battle_condition.py +++ b/objectives/conditions/_battle_condition.py @@ -1,70 +1,19 @@ -from memory.space import Bank, Write -import instruction.asm as asm -from objectives._cached_function import _CachedFunction +# battle variants of the asm objective conditions +# implementation shared with _menu_condition.py, see _asm_condition.py -import data.event_bit as event_bit -import data.battle_bit as battle_bit -import data.event_word as event_word +import objectives.conditions._asm_condition as _asm_condition -class _Condition(_CachedFunction, asm.JSR): - def __init__(self, *args, **kwargs): - _CachedFunction.__init__(self, *args, **kwargs) - asm.JSR.__init__(self, self.address(*args, **kwargs), asm.ABS) +class EventBitCondition(_asm_condition._EventBitCondition): + condition_type = "battle" -class _BitCondition(_Condition): - def write(self, address, bit, true, false): - if true is None: - true = [] - if false is None: - false = [] +class BattleBitCondition(_asm_condition._BattleBitCondition): + condition_type = "battle" - src = [ - asm.LDA(address, asm.ABS), - asm.AND(2 ** bit, asm.IMM8), - asm.BEQ("FALSE"), +class CharacterCondition(_asm_condition._CharacterCondition): + condition_type = "battle" - true, - asm.RTS(), +class EsperCondition(_asm_condition._EsperCondition): + condition_type = "battle" - "FALSE", - false, - asm.RTS(), - ] - return Write(Bank.F0, src, f"battle bit condition {hex(address)} {hex(bit)}") - -class EventBitCondition(_BitCondition): - def write(self, bit, true = None, false = None): - return super().write(event_bit.address(bit), event_bit.bit(bit), true, false) - -class BattleBitCondition(_BitCondition): - def write(self, bit, true = None, false = None): - return super().write(battle_bit.address(bit), battle_bit.bit(bit), true, false) - -class CharacterCondition(_BitCondition): - def write(self, character, true = None, false = None): - return super().write(event_bit.address(event_bit.character_recruited(character)), character % 8, true, false) - -class EsperCondition(_BitCondition): - def write(self, esper, true = None, false = None): - return super().write(0x1a69 + esper // 8, esper % 8, true, false) - -class EventWordCondition(_Condition): - def write(self, word, count, ge = None, lt = None): - if ge is None: - ge = [] - if lt is None: - lt = [] - - src = [ - asm.LDA(event_word.address(word), asm.ABS), - asm.CMP(count, asm.IMM8), - asm.BLT("LT"), - - ge, - asm.RTS(), - - "LT", - lt, - asm.RTS(), - ] - return Write(Bank.F0, src, f"battle word condition {hex(word)} {count}") +class EventWordCondition(_asm_condition._EventWordCondition): + condition_type = "battle" diff --git a/objectives/conditions/_menu_condition.py b/objectives/conditions/_menu_condition.py index dd0d6271..1b4ce6f7 100644 --- a/objectives/conditions/_menu_condition.py +++ b/objectives/conditions/_menu_condition.py @@ -1,70 +1,19 @@ -from memory.space import Bank, Write -import instruction.asm as asm -from objectives._cached_function import _CachedFunction +# menu variants of the asm objective conditions +# implementation shared with _battle_condition.py, see _asm_condition.py -import data.event_bit as event_bit -import data.battle_bit as battle_bit -import data.event_word as event_word +import objectives.conditions._asm_condition as _asm_condition -class _Condition(_CachedFunction, asm.JSR): - def __init__(self, *args, **kwargs): - _CachedFunction.__init__(self, *args, **kwargs) - asm.JSR.__init__(self, self.address(*args, **kwargs), asm.ABS) +class EventBitCondition(_asm_condition._EventBitCondition): + condition_type = "menu" -class _BitCondition(_Condition): - def write(self, address, bit, true, false): - if true is None: - true = [] - if false is None: - false = [] +class BattleBitCondition(_asm_condition._BattleBitCondition): + condition_type = "menu" - src = [ - asm.LDA(address, asm.ABS), - asm.AND(2 ** bit, asm.IMM8), - asm.BEQ("FALSE"), +class CharacterCondition(_asm_condition._CharacterCondition): + condition_type = "menu" - true, - asm.RTS(), +class EsperCondition(_asm_condition._EsperCondition): + condition_type = "menu" - "FALSE", - false, - asm.RTS(), - ] - return Write(Bank.F0, src, f"menu bit condition {hex(address)} {hex(bit)}") - -class EventBitCondition(_BitCondition): - def write(self, bit, true = None, false = None): - return super().write(event_bit.address(bit), event_bit.bit(bit), true, false) - -class BattleBitCondition(_BitCondition): - def write(self, bit, true = None, false = None): - return super().write(battle_bit.address(bit), battle_bit.bit(bit), true, false) - -class CharacterCondition(_BitCondition): - def write(self, character, true = None, false = None): - return super().write(event_bit.address(event_bit.character_recruited(character)), character % 8, true, false) - -class EsperCondition(_BitCondition): - def write(self, esper, true = None, false = None): - return super().write(0x1a69 + esper // 8, esper % 8, true, false) - -class EventWordCondition(_Condition): - def write(self, word, count, ge = None, lt = None): - if ge is None: - ge = [] - if lt is None: - lt = [] - - src = [ - asm.LDA(event_word.address(word), asm.ABS), - asm.CMP(count, asm.IMM8), - asm.BLT("LT"), - - ge, - asm.RTS(), - - "LT", - lt, - asm.RTS(), - ] - return Write(Bank.F0, src, f"menu word condition {hex(word)} {count}") +class EventWordCondition(_asm_condition._EventWordCondition): + condition_type = "menu" diff --git a/objectives/objective.py b/objectives/objective.py index 323429d9..2a326c13 100644 --- a/objectives/objective.py +++ b/objectives/objective.py @@ -128,5 +128,5 @@ def _init_suplex_train_quest_value(cls): if value != 'r' and quest_bit[value].name == cls.suplex_train_quest_name: cls.suplex_train_quest_value = value return - assert False, f"'{suplex_train_quest_name}' quest value not found" + raise RuntimeError(f"'{cls.suplex_train_quest_name}' quest value not found") Objective._init_suplex_train_quest_value() diff --git a/objectives/results/auto_clear.py b/objectives/results/auto_clear.py index 998333c9..7e99cd97 100644 --- a/objectives/results/auto_clear.py +++ b/objectives/results/auto_clear.py @@ -1,14 +1,14 @@ -from objectives.results._objective_result import * - -class Field(field_result.Result): - def src(self): - return [] - -class Battle(battle_result.Result): - def src(self): - return [] - -class Result(ObjectiveResult): - NAME = "Auto Clear" - def __init__(self): - super().__init__(Field, Battle) +from objectives.results._objective_result import * + +class Field(field_result.Result): + def src(self): + return [] + +class Battle(battle_result.Result): + def src(self): + return [] + +class Result(ObjectiveResult): + NAME = "Auto Clear" + def __init__(self): + super().__init__(Field, Battle) diff --git a/objectives/results/auto_dark.py b/objectives/results/auto_dark.py index b74c2d2b..2f548a08 100644 --- a/objectives/results/auto_dark.py +++ b/objectives/results/auto_dark.py @@ -1,14 +1,14 @@ -from objectives.results._objective_result import * - -class Field(field_result.Result): - def src(self): - return [] - -class Battle(battle_result.Result): - def src(self): - return [] - -class Result(ObjectiveResult): - NAME = "Auto Dark" - def __init__(self): - super().__init__(Field, Battle) +from objectives.results._objective_result import * + +class Field(field_result.Result): + def src(self): + return [] + +class Battle(battle_result.Result): + def src(self): + return [] + +class Result(ObjectiveResult): + NAME = "Auto Dark" + def __init__(self): + super().__init__(Field, Battle) diff --git a/objectives/results/auto_imp.py b/objectives/results/auto_imp.py index e52e0585..5598b030 100644 --- a/objectives/results/auto_imp.py +++ b/objectives/results/auto_imp.py @@ -1,14 +1,14 @@ -from objectives.results._objective_result import * - -class Field(field_result.Result): - def src(self): - return [] - -class Battle(battle_result.Result): - def src(self): - return [] - -class Result(ObjectiveResult): - NAME = "Auto Imp" - def __init__(self): - super().__init__(Field, Battle) +from objectives.results._objective_result import * + +class Field(field_result.Result): + def src(self): + return [] + +class Battle(battle_result.Result): + def src(self): + return [] + +class Result(ObjectiveResult): + NAME = "Auto Imp" + def __init__(self): + super().__init__(Field, Battle) diff --git a/seed.py b/seed.py index 07202008..748c7831 100644 --- a/seed.py +++ b/seed.py @@ -1,11 +1,13 @@ +from typing import Optional + SEED_LENGTH = 12 -def generate_seed(): +def generate_seed() -> str: import secrets, string alpha_digits = string.ascii_lowercase + string.digits return ''.join(secrets.choice(alpha_digits) for i in range(SEED_LENGTH)) -def seed_rng(seed = None, flags = ""): +def seed_rng(seed: Optional[str] = None, flags: str = "") -> str: if seed is None: seed = generate_seed() diff --git a/settings/config.py b/settings/config.py index b6743a1b..509d9e20 100644 --- a/settings/config.py +++ b/settings/config.py @@ -1,71 +1,71 @@ -from memory.space import Reserve, Bank, Write -import instruction.asm as asm -import args - -class Config: - def __init__(self): - self.mod() - - def mod(self): - # Thanks to DoctorDT for most of this code - - # Set default configuration options to the most popular: - # Config1: Msg Speed = 1 (Fastest), Bat Speed = 6 (Slowest), Bat Mode = 1 (Wait) - - # Config 1, set by this code: - # C3/70B8: A92A LDA #$2A ; Bat.Mode, etc. - # RAM $1D4D, one byte sets: cmmm wbbb (command set c, message spd mmm + 1, battle mode w, battle speed bbb + 1) - space = Reserve(0x370b9, 0x370b9, "config 1 default") - space.write(0x0D) # default: 0x2A - - # Moving default location for Config 2 and 3 to support command line re-configuration - # Set default memory location for Config #2: - src = [ - asm.LDA(0x00, asm.IMM8), # LDA #$00; - asm.STA(0x1D54, asm.ABS), # STA $1D54; # Config #2 - asm.RTS(), - ] - space = Write(Bank.C3, src, "Config #2 default value") - - # Update the JSR for Config default #2 - config2_loc = space.start_address - space = Reserve(0x370c2, 0x370c4, "Config_2_default") # 0x0370C2: ['20', PP, NN, '20', PP + 06, NN]]) # JSR #$CONF2; JSR #$CONF3 - space.write( - asm.JSR(config2_loc, asm.ABS), - ) - # Config 3, set by this code: - # C3/70C5: 9C4E1D STZ $1D4E ; Wallpaper, etc. - # RAM $1D4E, one byte sets: gcsr wwww (gauge g, cursor c, sound s, reequip r, wallpaper wwww (0-7)) - src = [ - asm.LDA(0x00, asm.IMM8), # default: 0 - asm.STA(0x1D4E, asm.ABS), - asm.RTS(), - ] - space = Write(Bank.C3, src, "Config_3_default") - - # Update the JSR for Config default #3 - config3_loc = space.start_address - space = Reserve(0x370c5, 0x370c7, "Config_3_default") - space.write( - asm.JSR(config3_loc, asm.ABS), - ) - - # Config 4, originally set by this code: - # C3/70C8: 9C4F1D STZ $1D4F ; Pad assignments - # RAM $1D4F, low nibble ----4321: bit (N - 1) set => player 2 controls - # battle character N when the Controller option is set to "multiple" - # (the in-game submenu reachable by pressing A on that row). Relocate - # it behind a JSR like Config 2/3 so the default is reconfigurable. - src = [ - asm.LDA(0x00, asm.IMM8), # default: 0 (player 1 controls everyone) - asm.STA(0x1D4F, asm.ABS), - asm.RTS(), - ] - space = Write(Bank.C3, src, "Config_4_default") - - # Update the JSR for Config default #4 - config4_loc = space.start_address - space = Reserve(0x370c8, 0x370ca, "Config_4_default") - space.write( - asm.JSR(config4_loc, asm.ABS), - ) +from memory.space import Reserve, Bank, Write +import instruction.asm as asm +import args + +class Config: + def __init__(self): + self.mod() + + def mod(self): + # Thanks to DoctorDT for most of this code + + # Set default configuration options to the most popular: + # Config1: Msg Speed = 1 (Fastest), Bat Speed = 6 (Slowest), Bat Mode = 1 (Wait) + + # Config 1, set by this code: + # C3/70B8: A92A LDA #$2A ; Bat.Mode, etc. + # RAM $1D4D, one byte sets: cmmm wbbb (command set c, message spd mmm + 1, battle mode w, battle speed bbb + 1) + space = Reserve(0x370b9, 0x370b9, "config 1 default") + space.write(0x0D) # default: 0x2A + + # Moving default location for Config 2 and 3 to support command line re-configuration + # Set default memory location for Config #2: + src = [ + asm.LDA(0x00, asm.IMM8), # LDA #$00; + asm.STA(0x1D54, asm.ABS), # STA $1D54; # Config #2 + asm.RTS(), + ] + space = Write(Bank.C3, src, "Config #2 default value") + + # Update the JSR for Config default #2 + config2_loc = space.start_address + space = Reserve(0x370c2, 0x370c4, "Config_2_default") # 0x0370C2: ['20', PP, NN, '20', PP + 06, NN]]) # JSR #$CONF2; JSR #$CONF3 + space.write( + asm.JSR(config2_loc, asm.ABS), + ) + # Config 3, set by this code: + # C3/70C5: 9C4E1D STZ $1D4E ; Wallpaper, etc. + # RAM $1D4E, one byte sets: gcsr wwww (gauge g, cursor c, sound s, reequip r, wallpaper wwww (0-7)) + src = [ + asm.LDA(0x00, asm.IMM8), # default: 0 + asm.STA(0x1D4E, asm.ABS), + asm.RTS(), + ] + space = Write(Bank.C3, src, "Config_3_default") + + # Update the JSR for Config default #3 + config3_loc = space.start_address + space = Reserve(0x370c5, 0x370c7, "Config_3_default") + space.write( + asm.JSR(config3_loc, asm.ABS), + ) + + # Config 4, originally set by this code: + # C3/70C8: 9C4F1D STZ $1D4F ; Pad assignments + # RAM $1D4F, low nibble ----4321: bit (N - 1) set => player 2 controls + # battle character N when the Controller option is set to "multiple" + # (the in-game submenu reachable by pressing A on that row). Relocate + # it behind a JSR like Config 2/3 so the default is reconfigurable. + src = [ + asm.LDA(0x00, asm.IMM8), # default: 0 (player 1 controls everyone) + asm.STA(0x1D4F, asm.ABS), + asm.RTS(), + ] + space = Write(Bank.C3, src, "Config_4_default") + + # Update the JSR for Config default #4 + config4_loc = space.start_address + space = Reserve(0x370c8, 0x370ca, "Config_4_default") + space.write( + asm.JSR(config4_loc, asm.ABS), + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_character.py b/tests/test_character.py new file mode 100644 index 00000000..537cd8fc --- /dev/null +++ b/tests/test_character.py @@ -0,0 +1,43 @@ +import unittest + +from data.character import Character + +def make_character(): + init_data = [0] * 22 + name_data = [0xff] * 6 # padding bytes only, i.e. an empty name + return Character(0, init_data, name_data) + +class TestInitRunSuccess(unittest.TestCase): + # run success is stored inverted in 2 bits of init_data[21]: + # 0b11 = 2, 0b10 = 3, 0b01 = 4, 0b00 = 5 (run_value = 5 - bit_value) + def test_getter_decodes_stored_bits(self): + character = make_character() + for raw, expected in ((0b11, 2), (0b10, 3), (0b01, 4), (0b00, 5)): + character._init_run_success = raw + self.assertEqual(character.init_run_success, expected) + + def test_setter_round_trip(self): + # regression test: the setter used to store (value - MAX) instead of + # (MAX - value), corrupting the bit-packed init data byte + character = make_character() + for value in range(Character.MIN_RUN_SUCCESS, Character.MAX_RUN_SUCCESS + 1): + character.init_run_success = value + self.assertEqual(character.init_run_success, value) + self.assertIn(character._init_run_success, (0b00, 0b01, 0b10, 0b11)) + + def test_setter_rejects_out_of_range(self): + character = make_character() + with self.assertRaises(ValueError): + character.init_run_success = Character.MIN_RUN_SUCCESS - 1 + with self.assertRaises(ValueError): + character.init_run_success = Character.MAX_RUN_SUCCESS + 1 + +class TestInitLevelFactor(unittest.TestCase): + def test_round_trip(self): + character = make_character() + for adjustment in (0, 2, 5, -3): + character.init_level_factor = adjustment + self.assertEqual(character.init_level_factor, adjustment) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..bf8e0d5d --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,23 @@ +import os +import subprocess +import sys +import unittest + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +class TestCli(unittest.TestCase): + def test_help_exits_successfully(self): + # smoke test: importing args parses all 30+ flag modules, so -h + # exercises the entire argument interface without needing a ROM + result = subprocess.run( + [sys.executable, "wc.py", "-h"], + cwd = REPO_ROOT, + capture_output = True, + text = True, + timeout = 60, + ) + self.assertEqual(result.returncode, 0, msg = result.stderr) + self.assertIn("usage", result.stdout) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_graphics_files.py b/tests/test_graphics_files.py new file mode 100644 index 00000000..9dedc6e1 --- /dev/null +++ b/tests/test_graphics_files.py @@ -0,0 +1,59 @@ +import os +import tempfile +import unittest + +from graphics.palette_file import PaletteFile +from graphics.sprite_file import SpriteFile + +class FakeColor: + def __init__(self, rgb): + self.rgb = rgb + +class FakePalette: + def __init__(self): + self.colors = [FakeColor([n, n, n]) for n in range(16)] + +class GraphicsFileTestCase(unittest.TestCase): + def write_temp(self, content): + with tempfile.NamedTemporaryFile(delete = False) as temp_file: + temp_file.write(content) + self.temp_path = temp_file.name + return self.temp_path + + def tearDown(self): + os.unlink(self.temp_path) + +class TestPaletteFile(GraphicsFileTestCase): + def test_valid_palette(self): + path = self.write_temp(bytes(32)) # 16 bgr15 colors + palette = PaletteFile(path) + self.assertEqual(len(palette), 16) + + def test_odd_size_rejected(self): + path = self.write_temp(bytes(33)) + with self.assertRaises(ValueError): + PaletteFile(path) + + def test_empty_rejected(self): + path = self.write_temp(b"") + with self.assertRaises(ValueError): + PaletteFile(path) + +class TestSpriteFile(GraphicsFileTestCase): + def test_valid_sprite(self): + path = self.write_temp(bytes(32 * 4)) # 4 tiles + sprite = SpriteFile(path, FakePalette()) + self.assertEqual(sprite.tile_count, 4) + + def test_partial_tile_rejected(self): + path = self.write_temp(bytes(32 * 4 + 1)) + with self.assertRaises(ValueError): + SpriteFile(path, FakePalette()) + + def test_empty_rejected(self): + path = self.write_temp(b"") + with self.assertRaises(ValueError): + SpriteFile(path, FakePalette()) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_heap.py b/tests/test_heap.py new file mode 100644 index 00000000..374d1021 --- /dev/null +++ b/tests/test_heap.py @@ -0,0 +1,94 @@ +import unittest + +from memory.errors import RomSpaceError +from memory.heap import Block, Heap + +class TestBlock(unittest.TestCase): + def test_size_is_inclusive_of_both_ends(self): + self.assertEqual(Block(0, 0).size, 1) + self.assertEqual(Block(0, 9).size, 10) + + def test_swapped_bounds_are_normalized(self): + block = Block(9, 0) + self.assertEqual(block.start, 0) + self.assertEqual(block.end, 9) + self.assertEqual(block.size, 10) + +class TestHeap(unittest.TestCase): + def setUp(self): + self.heap = Heap() + + def test_allocate_from_empty_heap_raises(self): + with self.assertRaises(RomSpaceError): + self.heap.allocate(1) + + def test_allocate_raises_memory_error_for_backward_compatibility(self): + with self.assertRaises(MemoryError): + self.heap.allocate(1) + + def test_free_then_allocate(self): + self.heap.free(0x100, 0x1ff) + self.assertEqual(self.heap.available, 0x100) + + start = self.heap.allocate(0x10) + self.assertEqual(start, 0x100) + self.assertEqual(self.heap.available, 0xf0) + + def test_allocate_uses_best_fit_block(self): + self.heap.free(0, 99) # 100 byte block + self.heap.free(200, 219) # 20 byte block + + start = self.heap.allocate(20) + self.assertEqual(start, 200) # exact fit preferred over larger block + self.assertEqual(self.heap.available, 100) + + def test_allocate_more_than_largest_block_raises(self): + self.heap.free(0, 99) + self.heap.free(200, 219) + with self.assertRaises(RomSpaceError): + self.heap.allocate(101) # 120 bytes available but largest block is 100 + + def test_free_merges_adjacent_blocks(self): + self.heap.free(0, 9) + self.heap.free(10, 19) + self.assertEqual(self.heap.available, 20) + self.assertEqual(len(self.heap.blocks), 1) + self.assertEqual(self.heap.allocate(20), 0) + + def test_free_merges_overlapping_blocks(self): + self.heap.free(0, 14) + self.heap.free(10, 19) + self.assertEqual(self.heap.available, 20) + self.assertEqual(len(self.heap.blocks), 1) + + def test_free_inside_existing_block_changes_nothing(self): + self.heap.free(0, 99) + self.heap.free(40, 59) + self.assertEqual(self.heap.available, 100) + self.assertEqual(len(self.heap.blocks), 1) + + def test_reserve_splits_free_block(self): + self.heap.free(0, 99) + self.heap.reserve(40, 59) + self.assertEqual(self.heap.available, 80) + + # neither remaining block can hold more than 40 bytes + with self.assertRaises(RomSpaceError): + self.heap.allocate(41) + self.assertEqual(self.heap.allocate(40), 0) + self.assertEqual(self.heap.allocate(40), 60) + + def test_reserve_trims_overlapping_block(self): + self.heap.free(0, 99) + self.heap.reserve(50, 120) + self.assertEqual(self.heap.available, 50) + self.assertEqual(self.heap.allocate(50), 0) + + def test_reserve_consumes_fully_covered_block(self): + self.heap.free(10, 19) + self.heap.reserve(0, 29) + self.assertEqual(self.heap.available, 0) + self.assertEqual(len(self.heap.blocks), 0) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_label.py b/tests/test_label.py new file mode 100644 index 00000000..3368409e --- /dev/null +++ b/tests/test_label.py @@ -0,0 +1,95 @@ +import unittest + +from memory.label import Label, LabelPointer + +class TestLabelPointer(unittest.TestCase): + def _pointer(self, label_address, pointer_address, mode, offset = 0): + label = Label("TEST") + label.address = label_address + pointer = LabelPointer(label, pointer_address, mode) + pointer.offset = offset + return pointer + + def test_absolute(self): + pointer = self._pointer(0x1234, 0x100, LabelPointer.ABSOLUTE) + self.assertEqual(int(pointer), 0x1234) + + def test_absolute_with_offset(self): + pointer = self._pointer(0x1234, 0x100, LabelPointer.ABSOLUTE) + pointer = pointer + 4 + self.assertEqual(int(pointer), 0x1238) + pointer = pointer - 8 + self.assertEqual(int(pointer), 0x1230) + + def test_relative(self): + pointer = self._pointer(0x110, 0x100, LabelPointer.RELATIVE) + self.assertEqual(int(pointer), 0x10) + + pointer = self._pointer(0x100, 0x110, LabelPointer.RELATIVE) + self.assertEqual(int(pointer), -0x10) + + def test_absolute_relative(self): + pointer = self._pointer(0x100, 0x110, LabelPointer.ABSOLUTE_RELATIVE) + self.assertEqual(int(pointer), 0x10) + + # 65c816 branch offsets are relative to the program counter after the + # one-byte operand, so the encoded value is (distance - 1) mod 256 + def test_branch_relative_forward(self): + pointer = self._pointer(0x110, 0x100, LabelPointer.BRANCH_RELATIVE) + self.assertEqual(int(pointer), 0x0f) + + def test_branch_relative_backward(self): + pointer = self._pointer(0x100, 0x110, LabelPointer.BRANCH_RELATIVE) + self.assertEqual(int(pointer), 0xef) # two's complement of -0x11 + + def test_branch_relative_backward_minimal(self): + # branching to the previous byte: distance -1, encoded offset -2 (0xfe) + pointer = self._pointer(0x0ff, 0x100, LabelPointer.BRANCH_RELATIVE) + self.assertEqual(int(pointer), 0xfe) + + def test_branch_relative_to_self(self): + # distance 0 targets the operand byte itself: encoded offset -1 (0xff) + # (used to fall through unhandled and encode 0x00) + pointer = self._pointer(0x100, 0x100, LabelPointer.BRANCH_RELATIVE) + self.assertEqual(int(pointer), 0xff) + + def test_branch_relative_boundaries(self): + # encodable distance range is [-127, 128] (offset range [-128, 127]) + pointer = self._pointer(0x100 + 128, 0x100, LabelPointer.BRANCH_RELATIVE) + self.assertEqual(int(pointer), 127) + + pointer = self._pointer(0x100 - 127, 0x100, LabelPointer.BRANCH_RELATIVE) + self.assertEqual(int(pointer), 0x80) + + # one past each boundary must raise (distance -128 used to be + # accepted and silently encoded as a forward branch) + pointer = self._pointer(0x100 + 129, 0x100, LabelPointer.BRANCH_RELATIVE) + with self.assertRaises(ValueError): + int(pointer) + + pointer = self._pointer(0x100 - 128, 0x100, LabelPointer.BRANCH_RELATIVE) + with self.assertRaises(ValueError): + int(pointer) + + def test_branch_relative_out_of_range_raises(self): + pointer = self._pointer(0x200, 0x100, LabelPointer.BRANCH_RELATIVE) + with self.assertRaises(ValueError): + int(pointer) + + pointer = self._pointer(0x100, 0x200, LabelPointer.BRANCH_RELATIVE) + with self.assertRaises(ValueError): + int(pointer) + + def test_to_bytes_little_endian(self): + pointer = self._pointer(0x1234, 0x100, LabelPointer.ABSOLUTE) + self.assertEqual(pointer.to_bytes(2, "little"), b"\x34\x12") + + def test_comparisons_use_pointed_to_address(self): + pointer = self._pointer(0x1234, 0x100, LabelPointer.ABSOLUTE) + self.assertTrue(pointer < 0x1235) + self.assertTrue(pointer <= 0x1234) + self.assertTrue(pointer > 0x1233) + self.assertTrue(pointer >= 0x1234) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_seed.py b/tests/test_seed.py new file mode 100644 index 00000000..fdda35f0 --- /dev/null +++ b/tests/test_seed.py @@ -0,0 +1,51 @@ +import random +import string +import unittest + +from seed import SEED_LENGTH, generate_seed, seed_rng + +class TestGenerateSeed(unittest.TestCase): + def test_length(self): + self.assertEqual(len(generate_seed()), SEED_LENGTH) + + def test_charset(self): + alpha_digits = set(string.ascii_lowercase + string.digits) + for _ in range(20): + self.assertTrue(set(generate_seed()) <= alpha_digits) + +class TestSeedRng(unittest.TestCase): + def setUp(self): + self._rng_state = random.getstate() + + def tearDown(self): + random.setstate(self._rng_state) + + def test_returns_given_seed(self): + self.assertEqual(seed_rng("abc123", "-i -o"), "abc123") + + def test_generates_seed_when_none_given(self): + seed = seed_rng(None, "") + self.assertEqual(len(seed), SEED_LENGTH) + + def test_deterministic_for_same_seed_and_flags(self): + # seed reproducibility is a core requirement: the same seed + flags + # must always produce the same random stream + seed_rng("abc123", "-flags") + first = [random.random() for _ in range(10)] + + seed_rng("abc123", "-flags") + second = [random.random() for _ in range(10)] + + self.assertEqual(first, second) + + def test_different_flags_produce_different_stream(self): + seed_rng("abc123", "-flags one") + first = random.random() + + seed_rng("abc123", "-flags two") + second = random.random() + + self.assertNotEqual(first, second) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_space.py b/tests/test_space.py new file mode 100644 index 00000000..8421017b --- /dev/null +++ b/tests/test_space.py @@ -0,0 +1,152 @@ +import unittest + +import memory.space +from memory.errors import RomSpaceError +from memory.heap import Heap +from memory.space import Allocate, Bank, Free, Reserve, Space, Write + +class FakeRom: + """Stand-in for memory.rom.ROM backed by a plain list (no ROM file needed).""" + def __init__(self, size = 0x20000): + self.data = [0] * size + + def set_bytes(self, address, values): + self.data[address : address + len(values)] = values + return address + len(values) + + def get_bytes(self, address, count): + return self.data[address : address + count] + + def get_byte(self, address): + return self.data[address] + +class SpaceTestCase(unittest.TestCase): + """Space uses class-level shared state; isolate and restore it per test.""" + def setUp(self): + self._saved_rom = Space.rom + self._saved_heaps = Space.heaps + self._saved_spaces = Space.spaces + + self.rom = FakeRom() + Space.rom = self.rom + Space.heaps = { bank : Heap() for bank in Bank } + Space.spaces = [] + + def tearDown(self): + Space.rom = self._saved_rom + Space.heaps = self._saved_heaps + Space.spaces = self._saved_spaces + +class TestSpaceWrite(SpaceTestCase): + def test_write_within_bounds(self): + space = Space(0x100, 0x10f, "test space") + space.write(1, 2, 3) + self.assertEqual(self.rom.get_bytes(0x100, 3), [1, 2, 3]) + self.assertEqual(space.next_address, 0x103) + + def test_writes_are_sequential(self): + space = Space(0x100, 0x10f, "test space") + space.write([1, 2]) + space.write([3, 4]) + self.assertEqual(self.rom.get_bytes(0x100, 4), [1, 2, 3, 4]) + + def test_write_nested_values_are_flattened(self): + space = Space(0x100, 0x10f, "test space") + space.write([1, [2, 3]], (4, 5), b"\x06") + self.assertEqual(self.rom.get_bytes(0x100, 6), [1, 2, 3, 4, 5, 6]) + + def test_write_exactly_full(self): + space = Space(0x100, 0x103, "test space") + space.write([1, 2, 3, 4]) + self.assertEqual(self.rom.get_bytes(0x100, 4), [1, 2, 3, 4]) + + def test_write_overflow_raises(self): + space = Space(0x100, 0x103, "test space") + with self.assertRaises(RomSpaceError): + space.write([1, 2, 3, 4, 5]) + + def test_write_overflow_does_not_modify_rom(self): + # regression test: overflow used to be detected only after the + # out-of-bounds bytes had already been written to the rom buffer + self.rom.data[0x104] = 0xaa # byte just past the end of the space + space = Space(0x100, 0x103, "test space") + with self.assertRaises(RomSpaceError): + space.write([1, 2, 3, 4, 5]) + self.assertEqual(self.rom.get_byte(0x104), 0xaa) + self.assertEqual(self.rom.get_bytes(0x100, 4), [0, 0, 0, 0]) + + def test_overflow_error_is_a_memory_error_for_backward_compatibility(self): + space = Space(0x100, 0x103, "test space") + with self.assertRaises(MemoryError): + space.write([1, 2, 3, 4, 5]) + +class TestSpaceLabels(SpaceTestCase): + def test_backward_branch_label_resolution(self): + space = Space(0x100, 0x10f, "test space") + space.write( + "LOOP", + 0xea, 0xea, # 2 placeholder bytes + 0x80, space.branch_distance("LOOP"), # BRA LOOP + ) + # branch operands stay as LabelPointer objects in the rom buffer and + # resolve via __index__; distance -3, encoded as (distance - 1) mod 256 + self.assertEqual(int(self.rom.get_byte(0x103)), 0xfc) + + def test_duplicate_label_raises(self): + space = Space(0x100, 0x10f, "test space") + space.write("LABEL", 0xea) + with self.assertRaises(ValueError): + space.write("LABEL", 0xea) + +class TestSpaceClear(SpaceTestCase): + def test_clear_with_single_value(self): + space = Space(0x100, 0x103, "test space", clear_value = 0xff) + self.assertEqual(self.rom.get_bytes(0x100, 4), [0xff] * 4) + + def test_clear_with_instruction_value(self): + # multi-byte clear values are callables with a len(), like the + # instruction objects from instruction/ (e.g. space.clear(field.NOP())) + class FakeInstruction: + def __len__(self): + return 2 + def __call__(self, space): + return [1, 2] + + space = Space(0x100, 0x103, "test space", clear_value = FakeInstruction()) + self.assertEqual(self.rom.get_bytes(0x100, 4), [1, 2, 1, 2]) + +class TestSpaceConflicts(SpaceTestCase): + def test_overlapping_spaces_raise(self): + Space(0x100, 0x1ff, "first") + with self.assertRaises(RuntimeError): + Space(0x180, 0x280, "second") + + def test_adjacent_spaces_allowed(self): + Space(0x100, 0x1ff, "first") + Space(0x200, 0x2ff, "second") # must not raise + +class TestAllocateReserveFree(SpaceTestCase): + def test_allocate_after_free(self): + Free(0x2000, 0x2fff) + space = Allocate(Bank["C0"], 0x100, "test allocation") + self.assertEqual(space.start_address, 0x2000) + self.assertEqual(len(space), 0x100) + + def test_allocate_without_free_space_raises(self): + with self.assertRaises(RomSpaceError): + Allocate(Bank["C0"], 0x100, "test allocation") + + def test_reserve_excludes_range_from_allocation(self): + Free(0x2000, 0x20ff) + Reserve(0x2000, 0x207f, "reserved range") + space = Allocate(Bank["C0"], 0x80, "test allocation") + self.assertEqual(space.start_address, 0x2080) + + def test_write_helper_allocates_and_writes(self): + Free(0x2000, 0x2fff) + space = Write(Bank["C0"], [1, 2, 3], "test write") + self.assertEqual(len(space), 3) + self.assertEqual(self.rom.get_bytes(space.start_address, 3), [1, 2, 3]) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sprite.py b/tests/test_sprite.py new file mode 100644 index 00000000..fe8b4a9b --- /dev/null +++ b/tests/test_sprite.py @@ -0,0 +1,59 @@ +import unittest + +from graphics.sprite import Sprite +from graphics.sprite_tile import SpriteTile + +class FakeColor: + def __init__(self, rgb): + self.rgb = rgb + +class FakePalette: + def __init__(self): + # 16 colors; color id n -> rgb (n, n, n) for easy assertions + self.colors = [FakeColor([n, n, n]) for n in range(16)] + +def make_sprite(tile_count = 4): + sprite = Sprite([], FakePalette()) + # tile n filled entirely with color id n via the data setter + data = [] + for n in range(tile_count): + tile = SpriteTile() + tile.colors = [[n] * SpriteTile.COL_COUNT for _ in range(SpriteTile.ROW_COUNT)] + data.extend(tile.data) + sprite.data = data + return sprite + +class TestSprite(unittest.TestCase): + def test_data_round_trip(self): + sprite = make_sprite() + self.assertEqual(sprite.tile_count, 4) + self.assertEqual(sprite.tiles[2].colors[0][0], 2) + + def test_rgb_data_single_tile(self): + sprite = make_sprite() + rgb = sprite.rgb_data([[1]]) + self.assertEqual(len(rgb), SpriteTile.ROW_COUNT * SpriteTile.COL_COUNT * 3) + self.assertEqual(set(rgb), {1}) # tile 1 is solid color id 1 -> rgb 1,1,1 + + def test_rgb_data_2x2_layout(self): + sprite = make_sprite() + pose = [[0, 1], [2, 3]] + rgb = sprite.rgb_data(pose) + width = SpriteTile.COL_COUNT * 2 + height = SpriteTile.ROW_COUNT * 2 + self.assertEqual(len(rgb), width * height * 3) + + # top-left pixel from tile 0, top-right from tile 1 + self.assertEqual(rgb[0], 0) + self.assertEqual(rgb[(width - 1) * 3], 1) + # bottom-left from tile 2, bottom-right from tile 3 + self.assertEqual(rgb[(height - 1) * width * 3], 2) + self.assertEqual(rgb[(height * width - 1) * 3], 3) + + def test_get_ppm_header(self): + sprite = make_sprite() + ppm = bytes(sprite.get_ppm([[0]])) + self.assertTrue(ppm.startswith(b"P6")) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..3e636989 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,128 @@ +import random +import unittest + +from utils.compression import compress, decompress +from utils.flatten import flatten +from utils.intersection import intersection +from utils.shuffle_if import shuffle_if +from utils.truncated_discrete_distribution import truncated_discrete_distribution +from utils.weighted_random import weighted_random + +class TestFlatten(unittest.TestCase): + def test_scalar(self): + self.assertEqual(flatten(5), [5]) + + def test_flat_list(self): + self.assertEqual(flatten([1, 2, 3]), [1, 2, 3]) + + def test_nested_lists_and_tuples(self): + self.assertEqual(flatten([1, [2, (3, 4)], [[5]]]), [1, 2, 3, 4, 5]) + + def test_bytes_are_flattened_to_ints(self): + self.assertEqual(flatten(b"\x01\x02"), [1, 2]) + + def test_strings_are_not_flattened(self): + # strings are labels in the memory model and must stay intact + self.assertEqual(flatten(["LABEL", 1]), ["LABEL", 1]) + + def test_empty(self): + self.assertEqual(flatten([]), []) + +class TestCompression(unittest.TestCase): + def assert_round_trip(self, data): + self.assertEqual(decompress(compress(data)), data) + + def test_round_trip_repetitive_data(self): + self.assert_round_trip([1, 2, 3] * 100) + + def test_round_trip_incompressible_data(self): + rng = random.Random(42) + self.assert_round_trip([rng.randrange(256) for _ in range(500)]) + + def test_round_trip_runs(self): + self.assert_round_trip([0] * 1000) + + def test_round_trip_mixed(self): + data = list(range(256)) + [7] * 50 + list(range(0, 256, 2)) * 3 + self.assert_round_trip(data) + + def test_size_header(self): + compressed = compress([1, 2, 3] * 10) + size = int.from_bytes(bytes(compressed[:2]), "little") + self.assertEqual(size, len(compressed)) + + def test_oversized_output_raises(self): + # incompressible data grows ~9/8x when compressed; this used to + # print an error and return a truncated size header instead of raising + rng = random.Random(42) + data = [rng.randrange(256) for _ in range(60000)] + with self.assertRaises(ValueError): + compress(data) + +class TestIntersection(unittest.TestCase): + def test_intersection_preserves_first_list_order(self): + self.assertEqual(intersection([3, 1, 2, 5], [2, 3]), [3, 2]) + + def test_disjoint(self): + self.assertEqual(intersection([1, 2], [3, 4]), []) + +class TestWeightedRandom(unittest.TestCase): + def test_returns_valid_index(self): + rng_state = random.getstate() + try: + random.seed("weighted_random test") + for _ in range(100): + self.assertIn(weighted_random([1, 2, 3]), (0, 1, 2)) + finally: + random.setstate(rng_state) + + def test_zero_weights_never_chosen(self): + rng_state = random.getstate() + try: + random.seed("weighted_random test") + for _ in range(100): + self.assertEqual(weighted_random([0, 1, 0]), 1) + finally: + random.setstate(rng_state) + +class TestTruncatedDiscreteDistribution(unittest.TestCase): + def setUp(self): + self._rng_state = random.getstate() + random.seed("truncated_discrete_distribution test") + + def tearDown(self): + random.setstate(self._rng_state) + + def test_respects_bounds(self): + for _ in range(200): + result = truncated_discrete_distribution(10, 5, 8, 12) + self.assertTrue(8 <= result <= 12) + + def test_impossible_bounds_raise(self): + # used to recurse forever; e.g. minimum above maximum + with self.assertRaises(ValueError): + truncated_discrete_distribution(10, 1, minimum = 110, maximum = 105) + +class TestShuffleIf(unittest.TestCase): + def test_only_matching_elements_move(self): + rng_state = random.getstate() + try: + random.seed("shuffle_if test") + values = list(range(20)) + original = list(values) + is_even = lambda value : value % 2 == 0 + + shuffle_if(values, is_even) + + # odd elements stay in place + for index, value in enumerate(original): + if not is_even(value): + self.assertEqual(values[index], value) + # even elements are permuted amongst themselves + self.assertEqual(sorted(v for v in values if is_even(v)), + sorted(v for v in original if is_even(v))) + finally: + random.setstate(rng_state) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_valid_rom_file.py b/tests/test_valid_rom_file.py new file mode 100644 index 00000000..0b6f9415 --- /dev/null +++ b/tests/test_valid_rom_file.py @@ -0,0 +1,25 @@ +import hashlib +import os +import tempfile +import unittest + +from valid_rom_file import get_sha256_hex, valid_rom_file + +class TestValidRomFile(unittest.TestCase): + def setUp(self): + with tempfile.NamedTemporaryFile(delete = False) as temp_file: + temp_file.write(b"not a rom" * 1000) + self.temp_path = temp_file.name + + def tearDown(self): + os.unlink(self.temp_path) + + def test_get_sha256_hex(self): + expected = hashlib.sha256(b"not a rom" * 1000).hexdigest() + self.assertEqual(get_sha256_hex(self.temp_path), expected) + + def test_invalid_rom_rejected(self): + self.assertFalse(valid_rom_file(self.temp_path)) + +if __name__ == "__main__": + unittest.main() diff --git a/utils/compression.py b/utils/compression.py index ab9afb1d..4613e18c 100644 --- a/utils/compression.py +++ b/utils/compression.py @@ -5,7 +5,7 @@ MAX_MULTI_LENGTH = 34 MAX_COMPRESS_SIZE = 2 ** 16 - 1 -def compress(data): +def compress(data) -> list: result = [] data_index = 0 @@ -73,11 +73,11 @@ def compress(data): size = len(result) + 2 if size > MAX_COMPRESS_SIZE: - print(f"Error: compress: data too large (compressed size {size} > 65535)") - size = MAX_COMPRESS_SIZE + # a wrong size header would silently corrupt the decompressed data + raise ValueError(f"compress: data too large (compressed size {size} > {MAX_COMPRESS_SIZE})") return list(size.to_bytes(2, "little")) + result -def decompress(data): +def decompress(data) -> list: window = [0] * WINDOW_SIZE window_index = WINDOW_START diff --git a/utils/flatten.py b/utils/flatten.py index 426d4b03..23291169 100644 --- a/utils/flatten.py +++ b/utils/flatten.py @@ -1,3 +1,3 @@ # flatten values into list -def flatten(values): +def flatten(values) -> list: return [y for x in values for y in flatten(x)] if isinstance(values, (list, tuple, bytes)) else [values] diff --git a/utils/intersection.py b/utils/intersection.py index 5bf90889..d60de9d9 100644 --- a/utils/intersection.py +++ b/utils/intersection.py @@ -1,5 +1,5 @@ -# find the intersection of two lists -# ref: https://www.geeksforgeeks.org/python-intersection-two-lists/# -def intersection(lst1, lst2): - lst3 = [value for value in lst1 if value in lst2] +# find the intersection of two lists +# ref: https://www.geeksforgeeks.org/python-intersection-two-lists/# +def intersection(lst1: list, lst2: list) -> list: + lst3 = [value for value in lst1 if value in lst2] return lst3 \ No newline at end of file diff --git a/utils/truncated_discrete_distribution.py b/utils/truncated_discrete_distribution.py index 921d9178..6403cc1a 100644 --- a/utils/truncated_discrete_distribution.py +++ b/utils/truncated_discrete_distribution.py @@ -1,9 +1,19 @@ # not a "real" distribution, the discretization and clamping skew it def truncated_discrete_distribution(mean, stddev, minimum = None, maximum = None): import random - result = round(random.gauss(mean, stddev)) - if minimum and result < minimum: - return truncated_discrete_distribution(mean, stddev, minimum, maximum) - if maximum and result > maximum: - return truncated_discrete_distribution(mean, stddev, minimum, maximum) - return result + + # rejection sampling: retry until a value lands within the bounds. + # the attempt cap turns impossible/near-impossible bounds into an error + # instead of an infinite loop; each attempt consumes exactly one + # random.gauss call, the same as the previous recursive implementation + MAX_ATTEMPTS = 10000 + for _ in range(MAX_ATTEMPTS): + result = round(random.gauss(mean, stddev)) + if minimum and result < minimum: + continue + if maximum and result > maximum: + continue + return result + raise ValueError(f"truncated_discrete_distribution: no value within " + f"[{minimum}, {maximum}] after {MAX_ATTEMPTS} attempts " + f"(mean {mean}, stddev {stddev})") diff --git a/utils/weighted_random.py b/utils/weighted_random.py index d51a0a4d..3a7b4cf8 100644 --- a/utils/weighted_random.py +++ b/utils/weighted_random.py @@ -1,5 +1,5 @@ # https://eli.thegreenplace.net/2010/01/22/weighted-random-generation-in-python/ -def weighted_random(weights): +def weighted_random(weights) -> int: import random rnd = random.random() * sum(weights) for i, w in enumerate(weights): diff --git a/valid_rom_file.py b/valid_rom_file.py index edd0258d..736cf590 100644 --- a/valid_rom_file.py +++ b/valid_rom_file.py @@ -3,7 +3,7 @@ HEADER_SIZE = 0x200 HEADER_FILE_SIZE = FILE_SIZE + HEADER_SIZE -def get_sha256_hex(file_path): +def get_sha256_hex(file_path: str) -> str: import hashlib BUFFER_SIZE = 65536 @@ -16,6 +16,6 @@ def get_sha256_hex(file_path): return sha256.hexdigest() -def valid_rom_file(file_path): +def valid_rom_file(file_path: str) -> bool: expected_sha256 = "0f51b4fca41b7fd509e4b8f9d543151f68efa5e97b08493e4b2a0c06f5d8d5e2" return get_sha256_hex(file_path) == expected_sha256