diff --git a/scripts/infinity-enable-debug.py b/scripts/infinity-enable-debug.py new file mode 100644 index 0000000..08ce750 --- /dev/null +++ b/scripts/infinity-enable-debug.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Enable or disable Pokémon Infinity debug mode by patching Data/Scripts.rxdata.""" + +from __future__ import annotations + +import argparse +import shutil +import sys +import zlib +from datetime import datetime +from pathlib import Path + +try: + from rubymarshal.reader import loads + from rubymarshal.writer import writes +except ImportError: + print("Missing dependency: pip install rubymarshal", file=sys.stderr) + sys.exit(1) + +RGSS2_SCRIPT_INDEX = 4 +DEBUG_FALSE = b"$DEBUG=false" +DEBUG_TRUE = b"$DEBUG=true" + + +def patch_scripts(path: Path, enable: bool) -> bool: + with path.open("rb") as fh: + scripts = loads(fh.read()) + + entry = scripts[RGSS2_SCRIPT_INDEX] + name = entry[1].decode() if isinstance(entry[1], bytes) else str(entry[1]) + if name != "RGSS2Compatibility": + raise ValueError(f"Unexpected script at index {RGSS2_SCRIPT_INDEX}: {name!r}") + + source = zlib.decompress(entry[2]) + has_true = DEBUG_TRUE in source + has_false = DEBUG_FALSE in source + + if enable: + if has_true and not has_false: + print("Debug mode already enabled.") + return False + if not has_false: + raise ValueError("Could not find $DEBUG=false in RGSS2Compatibility") + patched = source.replace(DEBUG_FALSE, DEBUG_TRUE, 1) + else: + if has_false and not has_true: + print("Debug mode already disabled.") + return False + if not has_true: + raise ValueError("Could not find $DEBUG=true in RGSS2Compatibility") + patched = source.replace(DEBUG_TRUE, DEBUG_FALSE, 1) + scripts[RGSS2_SCRIPT_INDEX] = [entry[0], entry[1], zlib.compress(patched, 9)] + + with path.open("wb") as fh: + fh.write(writes(scripts)) + + return True + + +def main() -> None: + parser = argparse.ArgumentParser(description="Toggle Pokémon Infinity debug mode.") + parser.add_argument( + "game_dir", + nargs="?", + default=r"C:\Users\MattC\roms\windows\Pokemon Infinity", + help="Path to the extracted game folder (contains Game.exe)", + ) + group = parser.add_mutually_exclusive_group() + group.add_argument("--enable", action="store_true", default=True, help="Enable debug (default)") + group.add_argument("--disable", action="store_true", help="Restore normal mode") + args = parser.parse_args() + + game_dir = Path(args.game_dir) + scripts_path = game_dir / "Data" / "Scripts.rxdata" + if not (game_dir / "Game.exe").is_file(): + raise SystemExit(f"Game.exe not found under {game_dir}") + if not scripts_path.is_file(): + raise SystemExit(f"Scripts.rxdata not found at {scripts_path}") + + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + backup = scripts_path.with_suffix(f".rxdata.bak.{stamp}") + shutil.copy2(scripts_path, backup) + print(f"Backup: {backup}") + + changed = patch_scripts(scripts_path, enable=not args.disable) + if changed: + state = "enabled" if not args.disable else "disabled" + print(f"Debug mode {state}.") + if not args.disable: + print() + print("In-game: open the pause menu -> Debug -> Set Money / Add Item") + print("Load screen also gets a Debug option. Back up saves before experimenting.") + + +if __name__ == "__main__": + main() diff --git a/scripts/infinity-money-trainer.py b/scripts/infinity-money-trainer.py new file mode 100644 index 0000000..5ab88a0 --- /dev/null +++ b/scripts/infinity-money-trainer.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Lightweight money editor for Pokémon Infinity (Essentials / RPG Maker XP). + +Edits @money on PokeBattle_Trainer in Game_*.rxdata save slots under: + %USERPROFILE%\\Saved Games\\Pokémon Infinity + +Close the game before running. A timestamped .bak backup is created first. +""" + +from __future__ import annotations + +import argparse +import glob +import os +import shutil +import sys +from datetime import datetime +from pathlib import Path + +try: + from rubymarshal.reader import loads + from rubymarshal.writer import writes +except ImportError: + print("Missing dependency: pip install rubymarshal", file=sys.stderr) + sys.exit(1) + +DEFAULT_SAVE_DIR = Path.home() / "Saved Games" / "Pokémon Infinity" +ALT_SAVE_DIR = Path.home() / "Saved Games" / "Pokmon Infinity" # typo seen in ludusavi manifest +DEFAULT_MONEY = 999_999 +MAX_MONEY = 9_999_999 + + +def resolve_save_dir(explicit: str | None) -> Path: + if explicit: + path = Path(explicit) + if not path.is_dir(): + raise SystemExit(f"Save directory not found: {path}") + return path + for candidate in (DEFAULT_SAVE_DIR, ALT_SAVE_DIR): + if candidate.is_dir(): + return candidate + raise SystemExit( + "Could not find Pokémon Infinity saves. Expected one of:\n" + f" {DEFAULT_SAVE_DIR}\n" + f" {ALT_SAVE_DIR}\n" + "Use --save-dir if yours is elsewhere." + ) + + +def list_save_files(save_dir: Path) -> list[Path]: + files = sorted(save_dir.glob("Game*.rxdata")) + return [f for f in files if f.name != "Settings.rxdata"] + + +def read_money(path: Path) -> int: + with path.open("rb") as fh: + trainer = loads(fh.read()) + if getattr(trainer, "ruby_class_name", None) != "PokeBattle_Trainer": + raise ValueError(f"{path.name} is not a trainer save (unexpected format)") + if "@money" not in trainer.attributes: + raise ValueError(f"{path.name} has no @money field") + return int(trainer.attributes["@money"]) + + +def write_money(path: Path, amount: int) -> None: + if amount < 0 or amount > MAX_MONEY: + raise ValueError(f"Money must be between 0 and {MAX_MONEY:,}") + + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + backup = path.with_suffix(path.suffix + f".bak.{stamp}") + shutil.copy2(path, backup) + + with path.open("rb") as fh: + trainer = loads(fh.read()) + trainer.attributes["@money"] = amount + with path.open("wb") as fh: + fh.write(writes(trainer)) + + print(f" backup -> {backup.name}") + + +def cmd_show(save_dir: Path) -> None: + files = list_save_files(save_dir) + if not files: + raise SystemExit(f"No Game*.rxdata files in {save_dir}") + print(f"Save folder: {save_dir}\n") + for path in files: + try: + money = read_money(path) + print(f" {path.name:16} ${money:,}") + except Exception as exc: # noqa: BLE001 - surface per-file issues + print(f" {path.name:16} (unreadable: {exc})") + + +def cmd_set(save_dir: Path, amount: int, slot: str | None) -> None: + files = list_save_files(save_dir) + if not files: + raise SystemExit(f"No Game*.rxdata files in {save_dir}") + + if slot: + matches = [f for f in files if f.stem.lower() == slot.lower() or f.name.lower() == slot.lower()] + if not matches: + matches = [f for f in files if slot in f.name] + if not matches: + raise SystemExit(f"No save matching slot {slot!r}. Available: {[f.name for f in files]}") + targets = matches + else: + targets = files + + print(f"Save folder: {save_dir}") + for path in targets: + old = read_money(path) + write_money(path, amount) + print(f" {path.name}: ${old:,} -> ${amount:,}") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Set money in Pokémon Infinity save files (close the game first)." + ) + parser.add_argument( + "--save-dir", + help="Override save directory (default: %%USERPROFILE%%\\Saved Games\\Pokémon Infinity)", + ) + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("show", help="Show money in each save slot") + + set_p = sub.add_parser("set", help="Set money in one or all slots") + set_p.add_argument( + "amount", + nargs="?", + type=int, + default=DEFAULT_MONEY, + help=f"Target money (default: {DEFAULT_MONEY:,})", + ) + set_p.add_argument( + "--slot", + help="Only edit this slot (e.g. Game_1 or Game_1.rxdata). Default: all Game*.rxdata", + ) + return parser + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + save_dir = resolve_save_dir(args.save_dir) + + if args.command == "show": + cmd_show(save_dir) + elif args.command == "set": + cmd_set(save_dir, args.amount, args.slot) + + +if __name__ == "__main__": + main() diff --git a/scripts/infinity-money.ps1 b/scripts/infinity-money.ps1 new file mode 100644 index 0000000..80af4c0 --- /dev/null +++ b/scripts/infinity-money.ps1 @@ -0,0 +1,38 @@ +<# +.SYNOPSIS + Quick money edit for Pokémon Infinity save files. + +.DESCRIPTION + Wrapper around scripts/infinity-money-trainer.py. Close the game first. + +.EXAMPLE + .\infinity-money.ps1 + .\infinity-money.ps1 -Amount 500000 + .\infinity-money.ps1 -Show +#> +[CmdletBinding()] +param( + [int]$Amount = 999999, + [string]$Slot, + [switch]$Show, + [string]$SaveDir +) + +$script = Join-Path $PSScriptRoot 'infinity-money-trainer.py' +if (-not (Test-Path -LiteralPath $script)) { + throw "Missing trainer script: $script" +} + +$args = @($script) +if ($SaveDir) { $args += @('--save-dir', $SaveDir) } + +if ($Show) { + $args += 'show' +} else { + $args += @('set', [string]$Amount) + if ($Slot) { $args += @('--slot', $Slot) } +} + +Write-Host "Pokémon Infinity money trainer" -ForegroundColor Cyan +Write-Host "Close the game before continuing." -ForegroundColor Yellow +python @args