Compare commits

...
10 Commits
Author SHA1 Message Date
ginnoir 79ed715dbb data: add Timeless Diamond + Spaceless Pearl (Aurum's D/P expansion mods) 2026-07-20 02:23:51 -05:00
ginnoirandClaude Opus 4.8 217ed639ca data: add Pokemblem (Fire Emblem Sacred Stones crossover)
Catalog 404 -> 405. Vesly's completed FE8 tactical reimagining of Kanto/Gen 1,
placed in RomM as roms/gba/Pokemblem [Hack].gba. First non-Pokémon-engine base
in the catalog; tagged New Experience + Crossover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:24:57 -05:00
ginnoirandClaude Opus 4.8 f97c29e90c data: add Hidden Place, Crippling Medical Debt, + 3 demo watchlist hacks
Catalog 399 -> 404. New: Pokémon Hidden Place (Essentials PC, MexaJ) and
Crippling Medical Debt Edition (Emerald/GBA, GMars) placed in RomM; plus
watchlist pages for Grimm, Conviction, and Miragea's Traveler (no download).
Crystal Inheritance restored to its shipped v1.0.3 Complete entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:52:37 -05:00
ginnoirandClaude Opus 4.8 4ed6dfc880 data: add Pokémon Nova (PhionGames Essentials PC fangame)
Ingested from Downloads: catalog now 399 hacks (PC 79). Nova placed in
RomM as roms/windows/Pokemon - Nova [Hack].zip (rom id 13345).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 15:08:14 -05:00
ginnoir fac5664f93 data: refresh romhack catalog 2026-07-04 19:01:11 -05:00
ginnoir 32fd6eda06 Fix Playnite setup help text and add Omniversal Emerald to catalog.
Comment out a broken here-string fragment in the PC hack setup instructions, and refresh wiki/catalog.json with Pokémon Omniversal Emerald plus updated facet counts.
2026-07-03 03:32:56 -05:00
ginnoir 9351f8b415 Add Pokémon Infinity money and debug-mode helpers.
These scripts edit Essentials save money offline and toggle the in-game debug menu so pokeball shopping does not require manual save hex editing.
2026-06-26 23:58:39 -05:00
ginnoir 44183fb0fb Complete catalog standardization: generations, banners, and Fakemon roster tag.
Adds standardize-vault-pages pipeline, exports intentional empty generations, and refreshes the 396-hack catalog with full banner coverage, status classification, and Fakemon generation tags.
2026-06-26 14:21:00 -05:00
ginnoirandClaude Opus 4.8 809600a04d feat: ROM-hack dedup, conflict resolution, and vault library_path resync
Catalog cleanup pass across gb/gbc/gba/nds/3ds:
- Standardize nonstandard hack filenames to `Pokemon - <Name> [Hack].<ext>`;
  remove verified byte-identical duplicates (conform-rom-names.sh).
- Resolve 9 version-conflicts via side-by-side emulator comparison
  (resolve-version-conflicts.sh): promote newer builds, keep genuine
  distinct hacks, fix a mislabeled Dark Rising slot (was the Digimon
  crossover "Worlds Collide"), and rebuild Mega Power 5.77 + HeartGold
  Generations v2.0 fresh from their patches.
- Resync 286 vault notes' `library_path` to the real on-disk [Hack] files
  and fix 15 stale platform fields (resync-vault-library-paths.py).
- Regenerate MOC tables + wiki/catalog.json (396 notes).

Also includes pending Playnite PC-hack setup/repair scripts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:29:05 -05:00
ginnoirandClaude Opus 4.8 ae56d101b7 refactor: standardize hack filenames to [Hack] and flatten to platform root
Switch the library naming convention from "Pokemon - <Name> (Hack)" to
"Pokemon - <Name> [Hack][version][tags]", and place hacks in the platform
root (roms/<plat>/) instead of a Hacks/ subfolder. Applied across the live
valhalla library (272 hack/fan-game files) via scripts/standardize-names.py.

- romhack-import.py / romhack-fetch.py: emit [Hack], write to roms/<plat>/
- romhack-pc-migrate.py: emit [Hack]
- build-romhack-vault.py (archived): update documented library_path
- standardize-names.py: new one-shot, self-fetching standardizer (idempotent;
  pipes \n-only bytes over SSH to avoid Windows CRLF corrupting filenames)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:08:31 -05:00
22 changed files with 10366 additions and 3093 deletions
+3
View File
@@ -22,3 +22,6 @@ Thumbs.db
.scrape/
.scrape_lib/
scripts/__pycache__/
# Per-machine ROM compare/patch scratch (side-by-side emulator runs, patch builds).
.compare/
+2 -2
View File
@@ -10,7 +10,7 @@ valhalla homelab library. Spun off from the `homelabstack` repo on 2026-06-06.
> or the `vault` skill. New hacks are added there **manually** (see the vault's
> `_Claude.md` checklist). This repo is just the tooling.
The ROMs live on valhalla under `/storage1/Emulation/roms/<platform>/Hacks/`,
The ROMs live on valhalla under `/storage1/Emulation/roms/<platform>/`,
served by the RomM stack (`roms.ginnoir.com`) — deployed from `homelabstack`
(`stacks/roms/`).
@@ -76,7 +76,7 @@ vault `Wanted` list.
They are served through the **same RomM stack** as the console hacks, under the
`windows` platform (RomM's "Microsoft Windows"; note `pc` is excluded in RomM's
`config.yml`, `windows` is not). Each hack is a single archive in the platform
**root**`Pokemon - <Hack> (Hack).zip` — NOT in a subfolder (a subfolder would
**root**`Pokemon - <Hack> [Hack].zip` — NOT in a subfolder (a subfolder would
make RomM treat the whole thing as one multi-file game).
```bash
+2 -2
View File
@@ -319,7 +319,7 @@ def gen_hack_note(h):
f'status: "{status}"',
f"type: {yaml_list(types)}",
f'generation: "{gen}"',
f'library_path: "roms/{plat}/Hacks/{libname}"',
f'library_path: "roms/{plat}/{libname}"',
f'source: "{url or lookup_url(title)}"',
f"added: {ADDED}",
f"tags: {yaml_list(tags)}",
@@ -343,7 +343,7 @@ def gen_hack_note(h):
f"- Lookup: {lookup_url(title)}",
"",
"## In the library",
f"- `roms/{plat}/Hacks/{libname}` on valhalla",
f"- `roms/{plat}/{libname}` on valhalla",
f"- Patch/source artifact archived under `/storage1/igir/romhacks/patches/`",
"",
"[[Index|← back to directory]]",
+20 -2
View File
@@ -100,10 +100,13 @@ def wikilink(stem: str) -> str:
# ---------------------------------------------------------------- MOC config
# Logical hardware order for human-facing lists.
PLATFORM_ORDER = ["GB", "GBC", "GBA", "NDS", "3DS", "PC", "Patch", DASH]
PLATFORM_ORDER = ["GB", "GBC", "GBA", "NDS", "3DS", "N64", "GameCube", "Switch", "PC", "Patch", DASH]
PLATFORM_LABEL = {
"GB": "Game Boy", "GBC": "Game Boy Color", "GBA": "Game Boy Advance",
"NDS": "Nintendo DS", "3DS": "Nintendo 3DS", "PC": "PC / Joiplay fan-games",
"NDS": "Nintendo DS", "3DS": "Nintendo 3DS", "N64": "Nintendo 64",
"GameCube": "Nintendo GameCube",
"Switch": "Nintendo Switch",
"PC": "PC / Joiplay fan-games",
"Patch": "Patch-only", DASH: "Unknown platform",
}
@@ -117,6 +120,9 @@ PLATFORM_MOCS = {
"GB": ("Game Boy (GB)", plat_pred("GB")),
"NDS": ("Nintendo DS (NDS)", plat_pred("NDS")),
"3DS": ("Nintendo 3DS (3DS)", plat_pred("3DS")),
"N64": ("Nintendo 64 (N64)", plat_pred("N64")),
"GameCube": ("Nintendo GameCube", plat_pred("GameCube")),
"Switch": ("Nintendo Switch", plat_pred("Switch")),
"PC": ("PC / Joiplay Fan-games", plat_pred("PC")),
"Patch": ("Patch-only", plat_pred("Patch")),
"Unknown": ("Unknown platform", plat_pred(DASH)),
@@ -128,6 +134,18 @@ NEW_PLATFORM_MOCS = {
"Nintendo 3DS Pokémon fan-games and hacks. A small but growing set — "
"mostly Citra/Luma-targeted projects harvested from the Discord catalog.",
'WHERE platform = "3DS"'),
"N64": ("N64",
"Nintendo 64 Pokémon mods and overhaul patches. These are tracked "
"separately from handheld ROM hacks because they target Nintendo 64-era base games.",
'WHERE platform = "N64"'),
"GameCube": ("GameCube",
"Nintendo GameCube Pokémon mods and overhaul patches. These are tracked "
"separately from handheld ROM hacks because they target GameCube-era base games.",
'WHERE platform = "GameCube"'),
"Switch": ("Switch",
"Nintendo Switch Pokémon mods and overhaul patches. These are tracked "
"separately from handheld ROM hacks because they target Switch-era base games.",
'WHERE platform = "Switch"'),
"PC": ("PC",
"RPG-Maker / Joiplay fan-games (run on PC or via Joiplay on Android). "
"These are standalone games, **not** console ROM hacks — they live under "
+5 -1
View File
@@ -20,8 +20,9 @@ OUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__)
OUT = os.path.join(OUT_DIR, "catalog.json")
SCALARS = ["title", "platform", "base", "version", "status", "generation",
"developer", "release_date", "banner", "homepage", "source",
"fakemon", "developer", "release_date", "banner", "homepage", "source",
"library_path", "play_status", "scrape_dir", "engine"]
LIST_SCALARS = ["generations"]
def section(body, heading):
@@ -57,6 +58,9 @@ def main():
for k in SCALARS:
if fm.get(k) not in (None, ""):
rec[k] = fm[k]
for k in LIST_SCALARS:
if k in fm:
rec[k] = fm[k]
rec["type"] = fm.get("type") or []
rec["tags"] = fm.get("tags") or []
if fm.get("rating") not in (None, ""):
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Conform nonstandard ROM-hack filenames to the `Pokemon - <Name> [Hack].<ext>`
# standard, and remove verified byte-identical duplicates. Runs on valhalla.
# DRY_RUN=1 bash conform-rom-names.sh # preview (default)
# DRY_RUN=0 bash conform-rom-names.sh # execute
set -u
ROOT=/storage1/Emulation/roms
DRY_RUN=${DRY_RUN:-1}
del(){ # del <platform> <file> — delete only if it still exists
local f="$ROOT/$1/$2"
if [ ! -f "$f" ]; then echo "SKIP del (missing): $1/$2"; return; fi
if [ "$DRY_RUN" = 1 ]; then echo "DEL $1/$2"; else rm -- "$f" && echo "DEL $1/$2"; fi
}
mv1(){ # mv1 <platform> <src> <dst> — rename only if dst does NOT exist
local s="$ROOT/$1/$2" d="$ROOT/$1/$3"
if [ ! -f "$s" ]; then echo "SKIP mv (missing src): $1/$2"; return; fi
if [ -e "$d" ]; then echo "SKIP mv (dst exists): $1/$3"; return; fi
if [ "$DRY_RUN" = 1 ]; then echo "MV $1/$2 -> $3"; else mv -n -- "$s" "$d" && echo "MV $1/$2 -> $3"; fi
}
echo "=== DELETE: verified byte-identical duplicates of an existing [Hack] file ==="
del gba "sors v1.3 .gba"
del gba "Pokemon FireRed Rocket Edition.gba"
del gba "Pokemon SOTS (v2.1.2).gba"
del gba "U_E_Inclement_Emerald_Beta_1.1.3.gba"
del gba "flora-sky-main-completed.gba"
del gba "Giratina Strikes Back Full.gba"
del gba "PK EMR Kaizo v2.0.gba"
echo
echo "=== RENAME: GBA ==="
mv1 gba "CHROME1.5.gba" "Pokemon - Chrome [Hack].gba"
mv1 gba "DarkVioletFinal2018V1.gba" "Pokemon - Dark Violet [Hack].gba"
mv1 gba "Energized v1.0.21.gba" "Pokemon - Energized [Hack].gba"
mv1 gba "Exceeded v11.5.gba" "Pokemon - Exceeded [Hack].gba"
mv1 gba "Hisui Red.gba" "Pokemon - Hisui Red [Hack].gba"
mv1 gba "Leaf Green Extreme Randomizer.gba" "Pokemon - Leaf Green Extreme Randomizer [Hack].gba"
mv1 gba "Moon_Galaxy_Completed.gba" "Pokemon - Moon Galaxy [Hack].gba"
mv1 gba "PKMN EMR PARTY RANDOMIZER.GBA" "Pokemon - Emerald Party Randomizer [Hack].gba"
mv1 gba "PerfectE v5.4 PSS.gba" "Pokemon - Perfect Emerald [Hack].gba"
mv1 gba "Pokemon Fire Red 898 Randomizer v1.2.3.gba" "Pokemon - Fire Red 898 Randomizer [Hack].gba"
mv1 gba "Pokemon Fusion Origins Ver 3.8.gba" "Pokemon - Fusion Origins [Hack].gba"
mv1 gba "Pokemon Ultimate Fusion.gba" "Pokemon - Ultimate Fusion [Hack].gba"
mv1 gba "Pokemon_Infernal_Legend_beta_2.1.GBA" "Pokemon - Infernal Legend [Hack].gba"
mv1 gba "Pokemon_Sunset_Orange_a1.0.gba" "Pokemon - Sunset Orange [Hack].gba"
mv1 gba "QuetzalSpanishAlpha8v4.gba" "Pokemon - Quetzal (Spanish) [Hack].gba"
mv1 gba "autumn orange v1.2 casual mode.gba" "Pokemon - Autumn Orange [Hack].gba"
mv1 gba "fr multiverse v1.6.gba" "Pokemon - FireRed Multiverse [Hack].gba"
mv1 gba "saffron-d2.gba" "Pokemon - Saffron [Hack].gba"
mv1 gba "the-unown-king.gba" "Pokemon - The Unown King [Hack].gba"
mv1 gba "wings of chaos alpha v2.5.gba" "Pokemon - Wings of Chaos [Hack].gba"
echo
echo "=== RENAME: 3DS ==="
mv1 3ds "Pokemon Binary Sun v1d3d1 - Patched by Ducumon.3ds" "Pokemon - Binary Sun [Hack].3ds"
mv1 3ds "Pokemon Omega Ruby QoL.cxi" "Pokemon - Omega Ruby QoL [Hack].cxi"
echo
echo "=== RENAME: NDS ==="
mv1 nds "Rainbow-Gold-v1.5.nds" "Pokemon - Rainbow Gold [Hack].nds"
mv1 nds "goldenshield.nds" "Pokemon - Golden Shield [Hack].nds"
mv1 nds "Pokemon Definitive HeartGold.nds" "Pokemon - Definitive HeartGold [Hack].nds"
mv1 nds "Pokemon Horror White v1.nds" "Pokemon - Horror White [Hack].nds"
mv1 nds "Pokemon Moon Black 2 (v4.2.3).nds" "Pokemon - Moon Black 2 [Hack].nds"
mv1 nds "Pokemon Black 2 Redux Complete (v1.4.1).nds" "Pokemon - Black 2 Redux [Hack].nds"
echo
echo "=== RENAME: phase 2 — web/header-identified hacks ==="
# Hyper EMR LA = Hyper Emerald v5.7 "Lost Artifacts" (Destvol et al.)
mv1 gba "Hyper EMR LA v5.7 bugfix 2.gba" "Pokemon - Hyper Emerald Lost Artifacts [Hack].gba"
# header BPEE/FireRed-base confirmations below
mv1 gba "darkfireb2.1.3.gba" "Pokemon - DarkFire [Hack].gba"
mv1 gba "MioPidgey.gba" "Pokemon - MioPidgey [Hack].gba"
mv1 gba "re universe.gba" "Pokemon - Re Universe [Hack].gba"
mv1 gba "TRE Johto Release April 30, 2025.gba" "Pokemon - Team Rocket Edition (DragonsDen) [Hack].gba"
# Elysium ships split A/B; this file is the Part B half
mv1 gba "Elysium Part B (v2.0).gba" "Pokemon - Elysium (Part B) [Hack].gba"
# ScalretFinal: product code CTR-P-ECLA = Alpha Sapphire base; "Scalret" = Scarlet
mv1 3ds "ScalretFinal.3ds" "Pokemon - Scarlet [Hack].3ds"
echo
echo "=== RENAME: phase 3 — user-confirmed IDs ==="
mv1 gba "Spirits.gba" "Pokemon - Spirits of the Storm [Hack].gba"
mv1 nds "EotS-v1.01.nds" "Pokemon - Mystery Dungeon - Explorers of the Spirit [Hack].nds"
mv1 gba "all-in-v1.0.gba" "Pokemon - All In [Hack].gba"
echo
echo "Done (DRY_RUN=$DRY_RUN)."
+10 -2
View File
@@ -53,6 +53,9 @@ PLATFORM_HINTS = [
(r"\bGBC\b|game boy color", ("GBC", "console")),
(r"\bNDS\b|nintendo ds\b|\bDS ROM", ("NDS", "console")),
(r"\b3DS\b", ("3DS", "console")),
(r"\bN64\b|Nintendo 64|Pokemon Stadium|Pokémon Stadium", ("N64", "console")),
(r"\bGameCube\b|Nintendo GameCube|Pokemon Colosseum|Pokémon Colosseum", ("GameCube", "console")),
(r"\bSwitch\b|Nintendo Switch", ("Switch", "console")),
(r"\bGB\b|game boy(?! advance| color)", ("GB", "console")),
(r"RPGXP|RPG ?Maker|Essentials|FanGame|fan game|GameMaker", ("PC", "fangame")),
]
@@ -256,6 +259,8 @@ GAME_PLATFORM = {
"soul silver": ("NDS", "SoulSilver"), "black": ("NDS", "Black"),
"white": ("NDS", "White"), "black 2": ("NDS", "Black 2"),
"white 2": ("NDS", "White 2"),
"stadium": ("N64", "Stadium"), "pokemon stadium": ("N64", "Stadium"),
"pokémon stadium": ("N64", "Stadium"),
}
@@ -389,7 +394,7 @@ def load_scrape():
# ---------------------------------------------------------------- frontmatter (de)serialize
FM_ORDER = ["title", "platform", "base", "version", "status", "type",
"generation", "developer", "release_date", "banner",
"generation", "generations", "fakemon", "developer", "release_date", "banner",
"library_path", "source", "homepage", "scrape_dir",
"added", "play_status", "rating", "tags"]
LIST_KEYS = {"type", "tags"}
@@ -462,7 +467,10 @@ def canon_status(s):
PLATFORM_LABEL = {"GB": "Game Boy", "GBC": "Game Boy Color",
"GBA": "Game Boy Advance", "NDS": "Nintendo DS",
"3DS": "Nintendo 3DS", "PC": "PC / Joiplay", "": "Unknown"}
"3DS": "Nintendo 3DS", "N64": "Nintendo 64",
"GameCube": "Nintendo GameCube",
"Switch": "Nintendo Switch",
"PC": "PC / Joiplay", "": "Unknown"}
def derive_tags(fm):
+50 -2
View File
@@ -20,7 +20,50 @@ import enrich_vault as E
FACTS_GLOB = os.path.join(E.SCRAPE, "web_facts*.json")
SCALAR_FIELDS = ["tagline", "developer", "version", "status", "release_date",
"base", "platform", "generation", "homepage"]
"base", "platform", "generation", "homepage", "source", "fakemon",
"banner"]
LIST_FIELDS = ["generations"]
def normalize_link_fact(link):
if isinstance(link, str):
return "Link", link
if isinstance(link, dict) and link.get("url"):
return link.get("label") or "Link", link["url"]
return None
def merge_links_section(body, links):
normalized = [x for x in (normalize_link_fact(link) for link in links) if x]
if not normalized:
return body
existing = ""
match = re.search(r"(?ms)^## Links\s*\n(.*?)(?=^## |\n\[\[Index|\Z)", body)
if match:
existing = match.group(1).strip()
pairs = []
for line in existing.splitlines():
url_match = re.search(r"(https?://\S+)", line)
if not url_match:
continue
label_match = re.match(r"^\s*-\s*([^:]+):", line)
pairs.append(((label_match.group(1).strip() if label_match else "Link"), url_match.group(1).rstrip(".,)")))
pairs.extend(normalized)
seen = set()
lines = []
for label, url in pairs:
if url in seen:
continue
seen.add(url)
lines.append(f"- {label}: {url}")
return set_section(body, "Links", "\n".join(lines))
def facts_sort_key(path: str) -> int:
match = re.search(r"web_facts(\d*)\.json$", os.path.basename(path))
if not match or not match.group(1):
return 0
return int(match.group(1))
def set_section(body, heading, content):
@@ -57,6 +100,9 @@ def apply_one(stem, facts):
for k in SCALAR_FIELDS:
if facts.get(k):
fm[k] = facts[k]
for k in LIST_FIELDS:
if k in facts:
fm[k] = facts[k]
if facts.get("type"):
fm["type"] = facts["type"]
fm["tags"] = E.derive_tags(fm)
@@ -77,6 +123,8 @@ def apply_one(stem, facts):
body = set_section(body, "Features", feats)
if facts.get("notability"):
body = set_section(body, "Why it stands out", facts["notability"].strip())
if facts.get("links"):
body = merge_links_section(body, facts["links"])
return f"---\n{E.emit_fm(fm)}\n---\n{body}", "ok"
@@ -84,7 +132,7 @@ def apply_one(stem, facts):
def main():
apply = "--apply" in sys.argv
facts = {}
for fp in sorted(glob.glob(FACTS_GLOB)):
for fp in sorted(glob.glob(FACTS_GLOB), key=facts_sort_key):
facts.update(json.load(open(fp, encoding="utf-8")))
ok = miss = 0
for stem, f in facts.items():
+96
View File
@@ -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()
+156
View File
@@ -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()
+38
View File
@@ -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
+67
View File
@@ -0,0 +1,67 @@
<#
.SYNOPSIS
Undo Playnite damage from setup-playnite-pc-hack.ps1 LiteDB writes.
Fixes:
1. Removes broken GameScannerConfig (null emulator, scoop\apps) from scanners.db
2. Clears active library filter that restricts view to RomM only
3. Disables pc_windows RomM mapping until emulator is recreated in Playnite UI
Playnite must be fully closed before running.
#>
$ErrorActionPreference = 'Stop'
if (Get-Process -Name 'Playnite*' -ErrorAction SilentlyContinue) {
throw 'Close Playnite completely, then re-run.'
}
$playniteConfig = Join-Path $env:LOCALAPPDATA 'Playnite\config.json'
$scannersDb = Join-Path $env:LOCALAPPDATA 'Playnite\library\scanners.db'
$rommConfig = Join-Path $env:LOCALAPPDATA 'Playnite\ExtensionsData\9700aa21-447d-41b4-a989-acd38f407d9f\config.json'
$liteDbDll = Join-Path $env:LOCALAPPDATA 'Playnite\LiteDB.dll'
$orphanEmuId = 'fa12bc5e-dbb9-4d21-b1e6-a67c2fa6a513'
$nullEmuId = '00000000-0000-0000-0000-000000000000'
# --- 1. Remove broken emulator scanner ---
Add-Type -Path $liteDbDll
$db = [LiteDB.LiteDatabase]::new("Filename=$scannersDb")
$col = $db.GetCollection('GameScannerConfig')
$removed = 0
foreach ($doc in @($col.FindAll())) {
$emuId = if ($doc['EmulatorId'].IsGuid) { $doc['EmulatorId'].AsGuid.ToString() } else { $doc['EmulatorId'].AsString }
$dir = if ($doc.ContainsKey('Directory')) { $doc['Directory'].AsString } else { '' }
if ($emuId -eq $nullEmuId -or $dir -like '*\scoop\apps') {
$col.Delete($doc['_id']) | Out-Null
$removed++
Write-Host "Removed broken scanner: Directory=$dir EmulatorId=$emuId"
}
}
$db.Dispose()
if ($removed -eq 0) { Write-Host 'No broken GameScannerConfig entries found.' }
# --- 2. Clear RomM-only library filter ---
$cfg = Get-Content -Raw $playniteConfig | ConvertFrom-Json
if ($cfg.FilterSettings.Library -and $cfg.FilterSettings.Library.Ids -and $cfg.FilterSettings.Library.Ids.Count -gt 0) {
$ids = @($cfg.FilterSettings.Library.Ids)
Write-Host "Clearing library filter (was: $($ids -join ', '))"
$cfg.FilterSettings.Library.Ids = @()
}
$cfg | ConvertTo-Json -Depth 20 | Set-Content -Encoding UTF8 $playniteConfig
Write-Host 'Playnite config: library filter cleared.'
# --- 3. Disable orphan pc_windows RomM mapping ---
$romm = Get-Content -Raw $rommConfig | ConvertFrom-Json
$pc = $romm.Mappings | Where-Object { $_.PlatformId -eq 'pc_windows' } | Select-Object -First 1
if ($pc) {
if ($pc.EmulatorId -eq $orphanEmuId) {
$pc.Enabled = $false
Write-Host "Disabled pc_windows RomM mapping (orphan emulator $orphanEmuId)."
Write-Host 'Re-enable after adding Pokemon PC Hack Launcher in Playnite UI.'
} else {
Write-Host "pc_windows mapping uses emulator $($pc.EmulatorId) - left enabled."
}
$romm | ConvertTo-Json -Depth 8 -Compress | Set-Content -Encoding UTF8 $rommConfig
}
Write-Host "`nDone. Open Playnite - full library should be visible again."
Write-Host 'Emulated-game scan errors on startup should be gone.'
+29
View File
@@ -0,0 +1,29 @@
# Rebuild emulators.db from readable BSON docs (fixes LiteDB page corruption).
$ErrorActionPreference = 'Stop'
Get-Process -Name 'Playnite*' -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 2
$lib = Join-Path $env:LOCALAPPDATA 'Playnite\library'
$src = Join-Path $lib 'emulators.db'
$new = Join-Path $lib 'emulators.db.new'
$bak = Join-Path $lib 'emulators.db.broken'
Add-Type -Path (Join-Path $env:LOCALAPPDATA 'Playnite\LiteDB.dll')
if (Test-Path $new) { Remove-Item -Force $new }
$srcDb = [LiteDB.LiteDatabase]::new("Filename=$src")
$dstDb = [LiteDB.LiteDatabase]::new("Filename=$new")
$srcCol = $srcDb.GetCollection('Emulator')
$dstCol = $dstDb.GetCollection('Emulator')
$n = 0
foreach ($doc in $srcCol.FindAll()) {
$dstCol.Insert($doc) | Out-Null
$n++
}
$srcDb.Dispose()
$dstDb.Dispose()
Move-Item -Force $src $bak
Move-Item -Force $new $src
Write-Host "Rebuilt emulators.db from $n documents."
Write-Host "Old file: $bak"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Resolve the side-by-side version-conflict decisions (pairs 1-4,6-8; 5 & 9 were
# swapped live via fresh patches). Guarded: del only if present, mv only if dst free.
# DRY_RUN=1 bash resolve-version-conflicts.sh (default)
# DRY_RUN=0 bash resolve-version-conflicts.sh
set -u
G=/storage1/Emulation/roms/gba
DRY_RUN=${DRY_RUN:-1}
del(){ local f="$1/$2"; if [ ! -f "$f" ]; then echo "SKIP del (missing): $2"; return; fi
if [ "$DRY_RUN" = 1 ]; then echo "DEL $2"; else rm -- "$f" && echo "DEL $2"; fi; }
mv1(){ local s="$1/$2" d="$1/$3"; if [ ! -f "$s" ]; then echo "SKIP mv (missing src): $2"; return; fi
if [ -e "$d" ]; then echo "SKIP mv (dst exists): $3"; return; fi
if [ "$DRY_RUN" = 1 ]; then echo "MV $2 -> $3"; else mv -n -- "$s" "$d" && echo "MV $2 -> $3"; fi; }
echo "=== deletes (older/superseded loose copies) ==="
del "$G" "Pokemon - Unbound [Hack].gba" # pair2: older 2.1.1, replaced by loose 2.1.1.1
del "$G" "supermariomon v1.3.gba" # pair6: older than catalog 1.5
del "$G" "HnS_v1.0.gba" # pair7: older than catalog 1.2.1
del "$G" "advanceredux 31-3-25 (2).gba" # pair8: 2020 build, catalog is 2026
echo
echo "=== pair3 Dark Rising: fix the mislabeled Digimon file, then promote the real DR1 ==="
mv1 "$G" "Pokemon - Dark Rising [Hack].gba" "Pokemon vs Digimon - Worlds Collide [Hack].gba"
mv1 "$G" "PokemonDarkRising1Complete.GBA" "Pokemon - Dark Rising [Hack].gba"
echo
echo "=== pair2 Unbound: promote the newer loose 2.1.1.1 ==="
mv1 "$G" "Pokemon Unbound.gba" "Pokemon - Unbound [Hack].gba"
echo
echo "=== pair1 GS Chronicles: keep both, disambiguate the Gold inc build ==="
mv1 "$G" "Pokemon GS Chronicles (Beta 2.7.1).gba" "Pokemon - GS Chronicles (Gold inc) [Hack].gba"
echo
echo "=== pair4 Dark Worship: keep both, conform the Super Dark Worship name ==="
mv1 "$G" "Super Dark Worship Oficial.gba" "Pokemon - Super Dark Worship [Hack].gba"
echo
echo "Done (DRY_RUN=$DRY_RUN)."
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Resync each vault hack note's `library_path` to the actual on-disk ROM file.
The [Hack] filename standardization renamed ROMs on disk but left the vault
notes' `library_path` frontmatter pointing at old names/paths. This matches each
note to its real file (by normalized title, falling back to the old path's
basename) and rewrites `library_path` + any exact body copy of the old value.
Read-only by default; pass --apply to write. ROM listings are read from
/tmp/romsync/<plat>.txt (one filename per line), pulled from valhalla.
"""
import os, re, sys, glob
VAULT = r"C:\Users\MattC\Documents\Obsidian Vault"
HACKS = os.path.join(VAULT, "Pokémon ROM Hacks", "Hacks")
LISTS = os.path.join(os.path.dirname(__file__), "..", ".scrape", "romsync")
PLAT = {"GBA": "gba", "GBC": "gbc", "GB": "gb", "NDS": "nds", "3DS": "3ds", "PC": "windows"}
APPLY = "--apply" in sys.argv
def norm(s):
s = os.path.splitext(s)[0].lower()
s = re.sub(r"\[[^\]]*\]", "", s) # drop [Hack], [2.0.5], [Full], ...
s = re.sub(r"\([^)]*\)", "", s) # drop (Gold inc), (Spanish), ...
s = re.sub(r"pok[eé]?mon", "", s) # drop the franchise prefix/word
s = re.sub(r"[^a-z0-9]", "", s) # keep bare digits (Dark Rising 2)
return s
# ---- build per-platform file index: normkey -> [filenames] ----
idx = {}
for path in glob.glob(os.path.join(LISTS, "*.txt")):
plat = os.path.splitext(os.path.basename(path))[0]
idx[plat] = {}
for line in open(path, encoding="utf-8"):
fn = line.strip()
if not fn or not re.search(r"\.(gba|gbc|gb|nds|3ds|cci|cxi|zip)$", fn, re.I):
continue
idx[plat].setdefault(norm(fn), []).append(fn)
def fm_get(text, key):
m = re.search(rf"(?m)^{key}:\s*(.*)$", text)
return m.group(1).strip().strip('"').strip("'") if m else None
FUZZY = "--fuzzy" in sys.argv # also write the proposed fuzzy/cross-platform matches
def find_exact_all(key):
"""Exact normalized-key match across ALL platforms (catches wrong platform field)."""
hits = []
for p, files in idx.items():
for fn in files.get(key, []):
hits.append((p, fn))
return hits
# Notes whose only fuzzy candidate is a different/ambiguous hack — held for manual
# review, never auto-applied even with --fuzzy.
FUZZY_HOLD = {
"Bloody Fusion.md", "Too Many Types.md", "HeartGold Generation.md",
"FireRed Reignited LeafGreen Regrown.md", "Kanto Complete.md",
"Mystery Dungeon - Wigglytuff's Bizarre Adventure.md",
}
def find_fuzzy(nk):
"""One key is a substring of the other; require a [Hack] file; must be unique.
Skip very short keys (e.g. a hack literally named 'Z') to avoid spurious hits."""
if len(nk) < 4: return []
hits = []
for p, files in idx.items():
for k, fns in files.items():
if len(k) < 4 or not nk: continue
if nk in k or k in nk:
for fn in fns:
if "[hack]" in fn.lower():
hits.append((p, fn))
return sorted(set(hits))
changed = unchanged = unmatched = ambiguous = 0
report = {"CHANGED": [], "UNMATCHED": [], "AMBIGUOUS": [], "FUZZY": []}
for md in sorted(glob.glob(os.path.join(HACKS, "*.md"))):
text = open(md, encoding="utf-8").read()
title = fm_get(text, "title") or os.path.splitext(os.path.basename(md))[0]
platform = (fm_get(text, "platform") or "").upper()
cur = fm_get(text, "library_path") or ""
plat = PLAT.get(platform)
note = os.path.basename(md)
if not plat or plat not in idx:
continue
def write_path(newpath):
if not APPLY: return
new = re.sub(r"(?m)^(library_path:\s*).*$", lambda m: m.group(1) + newpath, text, count=1)
# Update body copies ONLY when the old value was an emulation-rom path;
# never touch /storage1/labdata/... served-files links.
if cur and cur.startswith(("roms/", "/storage1/Emulation/")):
new = new.replace(cur, newpath)
open(md, "w", encoding="utf-8", newline="").write(new)
nk = norm(title)
# 1) exact match within the declared platform (auto-apply)
cand = idx[plat].get(nk) or (idx[plat].get(norm(os.path.basename(cur))) if cur else None)
if cand and len(cand) == 1:
newpath = f"roms/{plat}/{cand[0]}"
if cur == newpath: unchanged += 1; continue
changed += 1; report["CHANGED"].append((note, cur, newpath)); write_path(newpath); continue
if cand and len(cand) > 1:
ambiguous += 1; report["AMBIGUOUS"].append((note, cand)); continue
# 2) cross-platform exact, then fuzzy substring -> PROPOSAL (apply only with --fuzzy)
prop = find_exact_all(nk) or find_fuzzy(nk)
if len(prop) == 1:
p2, fn = prop[0]; newpath = f"roms/{p2}/{fn}"
if cur == newpath: unchanged += 1; continue
report["FUZZY"].append((note, cur, newpath))
if FUZZY and note not in FUZZY_HOLD: changed += 1; write_path(newpath)
continue
unmatched += 1; report["UNMATCHED"].append((note, platform, cur))
summary = (f"{'APPLIED' if APPLY else 'DRY-RUN'}{' +FUZZY' if FUZZY else ''}: {changed} changed, "
f"{unchanged} already correct, {len(report['FUZZY'])} fuzzy proposals, "
f"{unmatched} unmatched (no on-disk file — likely Wanted/gated), {ambiguous} ambiguous")
lines = [summary, ""]
for tag in ("AMBIGUOUS", "FUZZY", "CHANGED", "UNMATCHED"):
rows = report[tag]
if not rows: continue
lines.append(f"--- {tag} ({len(rows)}) ---")
for r in rows:
if tag in ("CHANGED", "FUZZY"): lines.append(f" {r[0]}\n {r[1] or ''} -> {r[2]}")
elif tag == "AMBIGUOUS": lines.append(f" {r[0]} -> {r[1]}")
else: lines.append(f" {r[0]} [{r[1]}] (cur: {r[2] or ''})")
lines.append("")
rep = os.path.join(os.path.dirname(__file__), "..", ".scrape", "resync-report.txt")
open(rep, "w", encoding="utf-8").write("\n".join(lines))
print(summary)
print("full report ->", os.path.normpath(rep))
+3 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""Acquire, patch, validate, and place worthwhile Pokémon ROM-hacks into the
EmuDeck library at /storage1/Emulation/roms/<platform>/Hacks/ on valhalla.
EmuDeck library at /storage1/Emulation/roms/<platform>/ on valhalla.
Each hack is one MANIFEST entry naming its source. Two source kinds:
- "rom": a pre-patched full ROM is downloaded and placed as-is (validated).
@@ -242,7 +242,7 @@ def main():
raise RuntimeError(f"validation: {verr}")
crc = zlib.crc32(open(src, "rb").read()) & 0xffffffff
if place:
dst_dir = os.path.join(ROMS, plat, "Hacks")
dst_dir = os.path.join(ROMS, plat)
os.makedirs(dst_dir, exist_ok=True)
shutil.copy2(src, os.path.join(dst_dir, out_name))
detail += " | PLACED"
@@ -253,7 +253,7 @@ def main():
for hk, pl, st, dt, nm in rows:
print(f"[{st:4}] {pl:4} {hk:<20} {dt}")
if st == "OK":
print(f" -> {pl}/Hacks/{nm}")
print(f" -> {pl}/{nm}")
print(f"\nplaced: {'YES (--place)' if place else 'NO (dry run; pass --place)'}")
if __name__ == "__main__":
+10 -10
View File
@@ -2,12 +2,12 @@
"""Import a drop-folder of mixed Pokémon hack files into the valhalla library.
Handles a folder containing any mix of:
- completed ROMs (.gba/.gbc/.gb/.nds) -> validated, clean-named, copied to roms/<plat>/Hacks/
- patches (.ips/.bps/.ups/.xdelta) -> applied to an owned base, placed in Hacks/, archived
- completed ROMs (.gba/.gbc/.gb/.nds) -> validated, clean-named, copied to roms/<plat>/
- patches (.ips/.bps/.ups/.xdelta) -> applied to an owned base, placed in roms/<plat>/, archived
- documentation (.pdf/.txt/.png/.md) -> archived under PATCHES/_docs/
- archives (.zip/.rar/.7z) -> extracted and recursed (same rules), source kept
Naming matches the rest of the library: "Pokemon - <Hack> (Hack).<ext>"
Naming matches the rest of the library: "Pokemon - <Hack> [Hack].<ext>"
(version/junk parentheticals are stripped from the source filename).
Base ROM for a patch is auto-detected: BPS/UPS embed source CRC32 so the correct
@@ -89,7 +89,7 @@ NAME_OVERRIDES = [
]
# Files we will NOT auto-place (unidentified / need user to name). Reported instead.
SKIP_NAME_SUBSTR = ["beta 15 + expansion"]
# Known base ROMs in a drop folder — place under roms/<plat>/, not Hacks/
# Known base ROMs in a drop folder — placed under roms/<plat>/ (un-tagged, no [Hack])
BASE_ROM_FILES = [
("fire red (j)", "gba", "Pokemon - Fire Red (J) (V1.0).gba"),
]
@@ -205,7 +205,7 @@ def place_rom(src, plat, hackname, origin):
if is_skip_name(origin):
report.append(("ROM", "NEEDS-ID", origin, "unidentified hack — tell me the name", ""))
return
out_name = f"Pokemon - {hackname} (Hack).{plat}"
out_name = f"Pokemon - {hackname} [Hack].{plat}"
err, padto = validate(src, plat)
if err:
report.append(("ROM", "FAIL", origin, err, "")); return
@@ -213,11 +213,11 @@ def place_rom(src, plat, hackname, origin):
if padto and len(data) < padto:
data += b"\xff" * (padto - len(data))
if PLACE:
d = os.path.join(ROMS, plat, "Hacks"); os.makedirs(d, exist_ok=True)
d = os.path.join(ROMS, plat); os.makedirs(d, exist_ok=True)
with open(os.path.join(d, out_name), "wb") as f:
f.write(data)
report.append(("ROM", "OK", origin, f"{len(data):,}b crc={zlib.crc32(data)&0xffffffff:08x}",
f"{plat}/Hacks/{out_name}"))
f"{plat}/{out_name}"))
def try_apply(patch, base_key, out):
@@ -314,8 +314,8 @@ def process_file(path, origin=None):
def place_pc_archive(path, hackname, origin, variant=None):
tag = variant or detect_variant(path) or ""
suffix = f" {tag}" if tag else ""
out_name = f"Pokemon - {hackname} (Hack){suffix}.zip"
suffix = tag if tag else ""
out_name = f"Pokemon - {hackname} [Hack]{suffix}.zip"
dest_dir = pc_dest_dir(tag)
dest = os.path.join(dest_dir, out_name)
ext = os.path.splitext(path)[1].lower()
@@ -352,7 +352,7 @@ def place_pc_archive(path, hackname, origin, variant=None):
def place_pc_installer(path, hackname, origin):
"""Wrap a standalone .exe installer in a zip so RomM can serve it."""
tag = detect_variant(path) or "[Installer]"
out_name = f"Pokemon - {hackname} (Hack) {tag}.zip"
out_name = f"Pokemon - {hackname} [Hack]{tag}.zip"
dest_dir = pc_dest_dir(tag)
dest = os.path.join(dest_dir, out_name)
tmp_zip = dest + ".part"
+2 -2
View File
@@ -8,7 +8,7 @@ and were never folded into RomM. This script places them as single archives into
the `windows` RomM platform so RomM catalogs them and the Playnite plugin can
download + extract + launch them like any other game.
Target: /storage1/Emulation/roms/windows/Pokemon - <Hack> (Hack).<ext>
Target: /storage1/Emulation/roms/windows/Pokemon - <Hack> [Hack].<ext>
- `windows` is the RomM "Microsoft Windows" platform (NOT excluded in RomM's
config.yml; `pc` IS excluded, so do not use it).
- Files go in the platform ROOT (mirrors the console-hack convention); a
@@ -166,7 +166,7 @@ def main():
src_ext = os.path.splitext(chosen)[1].lower()
will_repack = repack and src_ext in REPACK_EXT
out_ext = ".zip" if will_repack else src_ext
out_name = f"Pokemon - {clean_name(title)} (Hack){out_ext}"
out_name = f"Pokemon - {clean_name(title)} [Hack]{out_ext}"
dest = os.path.join(DEST, out_name)
note = " [repack rar/7z->zip]" if will_repack else (
" [RAR as-is; extract manually if Playnite balks]" if src_ext == ".rar" else "")
+31 -105
View File
@@ -1,127 +1,53 @@
<#
.SYNOPSIS
Configure Playnite + RomM plugin for Pokémon PC hacks (windows / pc_windows).
Configure RomM plugin mapping for PC hacks. Emulator must be added in Playnite UI (see below).
Playnite must be fully closed before running.
Playnite must be fully closed before running (only touches RomM plugin config.json).
#>
$ErrorActionPreference = 'Stop'
$PlayniteDir = Join-Path $env:LOCALAPPDATA 'Playnite'
$LiteDbDll = Join-Path $PlayniteDir 'LiteDB.dll'
$EmuDb = Join-Path $PlayniteDir 'library\emulators.db'
$RommConfig = Join-Path $PlayniteDir 'ExtensionsData\9700aa21-447d-41b4-a989-acd38f407d9f\config.json'
$Launcher = 'C:\Users\MattC\Documents\pokemon\scripts\launch-pc-hack.ps1'
$RommConfig = Join-Path $env:LOCALAPPDATA 'Playnite\ExtensionsData\9700aa21-447d-41b4-a989-acd38f407d9f\config.json'
$DestPath = 'C:\Users\MattC\roms\windows'
$EmuName = 'Pokemon PC Hack Launcher'
$PlatformSpec = 'pc_windows'
$ProfileName = 'Default'
$ProfileId = [guid]::NewGuid().ToString()
if (Get-Process -Name 'Playnite*' -ErrorAction SilentlyContinue) {
throw 'Close Playnite completely, then re-run this script.'
}
foreach ($p in @($LiteDbDll, $Launcher, $RommConfig)) {
if (-not (Test-Path $p)) { throw "Missing: $p" }
}
if (-not (Test-Path $RommConfig)) { throw "RomM config not found: $RommConfig" }
$pwshCmd = Get-Command pwsh -ErrorAction SilentlyContinue
if ($pwshCmd) { $pwsh = $pwshCmd.Source } else { $pwsh = (Get-Command powershell).Source }
$emu = Get-Content -Raw $RommConfig | ConvertFrom-Json | ForEach-Object { $_.Mappings } |
Where-Object { $_.PlatformId -eq $PlatformSpec } | Select-Object -First 1
Add-Type -Path $LiteDbDll
if (-not $emu -or -not $emu.EmulatorId) {
Write-Host @'
$installDir = Split-Path -Parent $Launcher
$arguments = "-ExecutionPolicy Bypass -File `"$Launcher`" `"{ImagePath}`""
Add the Playnite emulator first (Library -> Configure Emulators -> Add):
function New-BsonArray([object[]]$items) {
$list = New-Object 'System.Collections.Generic.List[LiteDB.BsonValue]'
foreach ($item in $items) {
if ($item -is [LiteDB.BsonDocument]) { $list.Add($item) }
elseif ($item -is [LiteDB.BsonValue]) { $list.Add($item) }
else { $list.Add([LiteDB.BsonValue]$item) }
}
return [LiteDB.BsonArray]::new($list)
}
Name: Pokemon PC Hack Launcher
Install: C:\Users\MattC\Documents\pokemon\scripts
Profile: Default
Platforms: PC (Windows)
Extensions: zip
Executable: C:\Program Files\PowerShell\7\pwsh.exe
Arguments: -ExecutionPolicy Bypass -File "C:\Users\MattC\Documents\pokemon\scripts\launch-pc-hack.ps1" "{ImagePath}"
Working dir: {InstallDir}
$profileDoc = [LiteDB.BsonDocument]::new()
$profileDoc['_id'] = [LiteDB.BsonValue][guid]::Parse($ProfileId)
$profileDoc['Name'] = $ProfileName
$profileDoc['Platforms'] = New-BsonArray @($PlatformSpec)
$profileDoc['ImageExtensions'] = New-BsonArray @('zip')
$profileDoc['Executable'] = $pwsh
$profileDoc['Arguments'] = $arguments
$profileDoc['WorkingDirectory'] = '{InstallDir}'
$profileDoc['UseShellExecute'] = $false
$profileDoc['OverrideDefaultArgs'] = $false
$profileDoc['ScriptStartup'] = $false
$profileDoc['ScriptGameImport'] = $false
$profileDoc['TrackingMode'] = 0
$profileDoc['TrackingPath'] = $null
Then in RomM plugin settings, map pc_windows -> that emulator, destination
#'@ + $DestPath + @', Auto-extract ON.
$db = [LiteDB.LiteDatabase]::new("Filename=$EmuDb")
try {
$col = $db.GetCollection('Emulator')
$existing = $col.FindOne([LiteDB.Query]::EQ('Name', $EmuName))
if ($existing) {
$emuDoc = $existing
$emuId = $existing['_id'].AsGuid.ToString()
if ($emuId -eq '00000000-0000-0000-0000-000000000000') {
$emuId = $existing['_id'].AsString
}
$profiles = $emuDoc['CustomProfiles'].AsArray
if ($profiles.Count -gt 0) {
$p0 = $profiles[0]
if ($p0['_id'].IsGuid) { $ProfileId = $p0['_id'].AsGuid.ToString() }
else { $ProfileId = $p0['_id'].AsString }
$profileDoc['_id'] = [LiteDB.BsonValue][guid]::Parse($ProfileId)
}
Write-Host "Updating existing emulator '$EmuName' ($emuId)"
} else {
$emuId = [guid]::NewGuid().ToString()
$emuDoc = [LiteDB.BsonDocument]::new()
$emuDoc['_id'] = [LiteDB.BsonValue][guid]::Parse($emuId)
$profileDoc['_id'] = [LiteDB.BsonValue][guid]::Parse($ProfileId)
Write-Host "Creating emulator '$EmuName' ($emuId)"
}
Or re-run this script after the emulator exists (it reads EmulatorId from an existing pc_windows mapping).
$emuDoc['Name'] = $EmuName
$emuDoc['InstallDir'] = $installDir
$emuDoc['BuiltInConfigId'] = $null
$profilesList = New-Object 'System.Collections.Generic.List[LiteDB.BsonValue]'
$profilesList.Add($profileDoc)
$emuDoc['CustomProfiles'] = [LiteDB.BsonArray]::new($profilesList)
$col.Upsert($emuDoc) | Out-Null
} finally {
$db.Dispose()
'@
exit 1
}
$cfg = Get-Content -Raw $RommConfig | ConvertFrom-Json
$mapping = $cfg.Mappings | Where-Object { $_.PlatformId -eq $PlatformSpec } | Select-Object -First 1
if ($mapping) {
$mapping.EmulatorId = $emuId
$mapping.EmulatorProfileId = $ProfileId
$mapping.DestinationPath = $DestPath
$mapping.AutoExtract = $true
$mapping.Enabled = $true
$mapping.SyncSaves = $false
Write-Host 'Updated RomM mapping for pc_windows.'
} else {
$cfg.Mappings += [pscustomobject]@{
MappingId = [guid]::NewGuid()
Enabled = $true
AutoExtract = $true
UseM3u = $false
SyncSaves = $false
SaveStrategy = 0
SaveDirOverride = $null
EmulatorId = $emuId
EmulatorProfileId = $ProfileId
PlatformId = $PlatformSpec
DestinationPath = $DestPath
}
Write-Host 'Added RomM mapping for pc_windows.'
$m = $cfg.Mappings | Where-Object { $_.PlatformId -eq $PlatformSpec } | Select-Object -First 1
if (-not $m) {
Write-Host 'No pc_windows mapping yet — add it in RomM plugin settings after creating the emulator.'
exit 1
}
$m.DestinationPath = $DestPath
$m.AutoExtract = $true
$m.Enabled = $true
$m.SyncSaves = $false
New-Item -ItemType Directory -Force -Path $DestPath | Out-Null
$cfg | ConvertTo-Json -Depth 8 -Compress | Set-Content -Encoding UTF8 $RommConfig
Write-Host "Playnite configured. ROM download dir: $DestPath"
Write-Host "Open Playnite -> Menu -> Library -> Import RomM library (windows games)."
Write-Host "RomM pc_windows mapping updated (destination $DestPath, AutoExtract on)."
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
Standardize valhalla Hack-library filenames to:
Pokemon - <Name> [Hack][<version>][<functional tags>].<ext>
Rule-based + self-fetching: queries valhalla for every file still tagged
"(Hack)" anywhere under the roms tree and rewrites it.
- mechanical: " (Hack)" -> " [Hack]" (preserves other (...) like (FRLG+))
- OVERRIDES: platform-port fan-games whose stem carries "-<ver>-<os>"
get the version pulled into [ver] and the OS kept as a tag.
- SKIP: non-game patcher utilities are left untouched.
Dry-run prints OLD -> NEW; --apply runs `mv -n` over SSH.
Idempotent: already-[Hack] files don't match the (Hack) query, so re-runs
are no-ops.
"""
import subprocess, sys, shlex, os
APPLY = "--apply" in sys.argv
HOST = "valhalla"
ROOT = "/storage1/Emulation/roms"
# Non-game utilities — leave alone.
SKIP = {
"Pokemon - DeltaPatcherLite (Hack) [Installer].zip",
"Pokemon - Patcher (Hack) [Installer].zip",
}
# Stem-encoded version/OS ports -> canonical [Hack][ver][OS].
OVERRIDES = {
"Pokemon - Reborn-19.5.0-linux (Hack) [Linux].zip": "Pokemon - Reborn [Hack][19.5.0][Linux].zip",
"Pokemon - Rejuvenation-13.5.0-linux (Hack) [Linux].zip": "Pokemon - Rejuvenation [Hack][13.5.0][Linux].zip",
"Pokemon - Reborn-19.5.0-macos (Hack) [macOS].zip": "Pokemon - Reborn [Hack][19.5.0][macOS].zip",
"Pokemon - Rejuvenation-13.5.0-macos (Hack) [macOS].zip": "Pokemon - Rejuvenation [Hack][13.5.0][macOS].zip",
}
def new_name(base):
if base in SKIP:
return None
if base in OVERRIDES:
return OVERRIDES[base]
if " (Hack)" in base:
return base.replace(" (Hack)", " [Hack]")
return None
def fetch():
cmd = f"find {shlex.quote(ROOT)} -type f -name '*(Hack)*'"
out = subprocess.run(["ssh", HOST, cmd], capture_output=True, text=True, check=True).stdout
return sorted(p for p in out.splitlines() if p.strip())
def main():
paths = fetch()
cmds, special, mech, skipped = [], [], 0, []
for full in paths:
d, base = os.path.dirname(full), os.path.basename(full)
nn = new_name(base)
if nn is None:
skipped.append(base)
continue
cmds.append(f"mv -n -- {shlex.quote(full)} {shlex.quote(d + '/' + nn)}")
if base in OVERRIDES:
special.append((base, nn))
else:
mech += 1
print(f"Found {len(paths)} files still tagged (Hack).\n")
print(f"Mechanical (Hack)->[Hack] swaps : {mech}")
print(f"Special version/OS overrides : {len(special)}")
for o, n in special:
print(f" {o}\n -> {n}")
print(f"Skipped (non-game utilities) : {len(skipped)}")
for s in skipped:
print(f" {s}")
if not APPLY:
print("\n(dry-run — pass --apply to execute over SSH)")
return
# NB: pipe bytes with explicit \n line endings. On Windows, text=True wraps
# stdin in a TextIOWrapper that rewrites \n -> \r\n, which bash would append
# as a stray CR onto every destination filename. Encoding ourselves avoids it.
script = "set -e\n" + "\n".join(cmds) + "\n"
r = subprocess.run(["ssh", HOST, "bash -s"], input=script.encode("utf-8"))
print(f"\napplied {len(cmds)} renames (exit {r.returncode})")
sys.exit(r.returncode)
if __name__ == "__main__":
main()
+478
View File
@@ -0,0 +1,478 @@
#!/usr/bin/env python3
"""Standardize ROM-hack vault pages for site export.
This pass is intentionally conservative: it uses the current vault note text,
existing scrape metadata, and deterministic inference rules. Web-researched facts
belong in .scrape/web_facts*.json and should be applied with enrich_web.py.
Run:
python scripts/standardize-vault-pages.py
python scripts/standardize-vault-pages.py --apply
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from collections import Counter
from urllib.parse import urlparse
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import enrich_vault as E
GEN_ROMAN = {
1: "Gen I",
2: "Gen II",
3: "Gen III",
4: "Gen IV",
5: "Gen V",
6: "Gen VI",
7: "Gen VII",
8: "Gen VIII",
9: "Gen IX",
}
ROMAN_TO_INT = {
"i": 1,
"ii": 2,
"iii": 3,
"iv": 4,
"v": 5,
"vi": 6,
"vii": 7,
"viii": 8,
"ix": 9,
}
REGIONS = ["Alolan", "Galarian", "Hisuian", "Paldean"]
FAKEMON_GEN_TAG = "Fakemon"
PURE_CUSTOM_DEX_STEMS = {
"Cope",
"Fakemon Fire Red",
"Pisces",
"Solar Light Lunar Dark",
"Touhoumon Another World",
"Void",
}
GENERATION_SORT_ORDER = list(GEN_ROMAN.values()) + REGIONS + [FAKEMON_GEN_TAG]
COUNT_TO_GEN = [
(1025, 9),
(1008, 9),
(905, 8),
(898, 8),
(809, 7),
(807, 7),
(721, 6),
(649, 5),
(493, 4),
(386, 3),
(251, 2),
(151, 1),
]
BASE_MAX_GEN = {
"Red": 1,
"Red and Blue": 1,
"Blue": 1,
"Yellow": 1,
"Stadium": 1,
"Gold": 2,
"Silver": 2,
"Crystal": 2,
"Ruby": 3,
"Sapphire": 3,
"Emerald": 3,
"FireRed": 3,
"LeafGreen": 3,
"Diamond": 4,
"Pearl": 4,
"Platinum": 4,
"HeartGold": 4,
"SoulSilver": 4,
"Black": 5,
"White": 5,
"Black / White": 5,
"Pokemon Black & White": 5,
"Black 2": 5,
"White 2": 5,
"X": 6,
"Y": 6,
"Omega Ruby": 6,
"Alpha Sapphire": 6,
"Sun": 7,
"Moon": 7,
"Ultra Sun": 7,
"Ultra Moon": 7,
"Ultra Sun / Ultra Moon": 7,
"Sword": 8,
"Shield": 8,
"Sword / Shield": 8,
"Scarlet": 9,
"Violet": 9,
"Scarlet / Violet": 9,
"XD: Gale of Darkness": 3,
}
BAD_BASE_VALUES = {
"how far you are into the journey",
"precedents set",
"the north atlantic island nation of Iceland",
}
def gen_label_to_int(raw: str) -> int | None:
value = raw.strip().lower().replace("generation", "").replace("gen", "").strip()
value = value.strip(" .:-")
if value.isdigit():
n = int(value)
return n if 1 <= n <= 9 else None
return ROMAN_TO_INT.get(value)
def gens_through(n: int) -> list[str]:
return [GEN_ROMAN[i] for i in range(1, n + 1)]
def unique_ordered(values: list[str]) -> list[str]:
seen = set()
out = []
for value in values:
if value and value not in seen:
seen.add(value)
out.append(value)
return out
def sort_generations(generations: list[str]) -> list[str]:
rank = {label: index for index, label in enumerate(GENERATION_SORT_ORDER)}
return unique_ordered(sorted(generations, key=lambda label: rank.get(label, len(GENERATION_SORT_ORDER))))
def apply_fakemon_generation_tag(fm: dict, stem: str) -> list[str]:
generations = [g for g in (fm.get("generations") or []) if g != FAKEMON_GEN_TAG]
if fm.get("fakemon") != "Yes":
return sort_generations(generations)
if stem in PURE_CUSTOM_DEX_STEMS:
return [FAKEMON_GEN_TAG]
return sort_generations(generations + [FAKEMON_GEN_TAG])
def note_sections(body: str) -> dict[str, str]:
sections: dict[str, str] = {}
for match in re.finditer(r"(?ms)^## ([^\n]+)\s*\n+(.+?)(?=^## |\n\[\[Index|\Z)", body):
sections[match.group(1).strip()] = match.group(2).strip()
return sections
def set_section(body: str, heading: str, content: str) -> str:
block = f"## {heading}\n\n{content.rstrip()}\n\n"
pattern = re.compile(rf"(?ms)^## {re.escape(heading)}\s*\n.*?(?=^## |\n\[\[Index|\Z)")
if pattern.search(body):
return pattern.sub(block, body, count=1)
summary = re.search(r"(?ms)^## Summary\s*\n.*?(?=^## |\n\[\[Index|\Z)", body)
if summary:
return body[: summary.end()] + block + body[summary.end() :]
footer = re.search(r"(?m)^\[\[Index", body)
if footer:
return body[: footer.start()] + block + body[footer.start() :]
return body.rstrip() + "\n\n" + block
def clean_summary(text: str) -> str:
text = re.sub(r"\s+", " ", text).strip()
text = re.sub(r"\s*-\s*(?:Rom link|Download|wiki|Link)\s*:?\s*https?://\S+", "", text, flags=re.I)
return text.strip()
def extract_urls(text: str) -> list[str]:
urls = []
for match in re.finditer(r"https?://[^\s)\]>'\"]+", text):
urls.append(match.group(0).rstrip(".,)"))
return unique_ordered(urls)
def link_label(url: str) -> str:
host = urlparse(url).netloc.lower()
if "pokecommunity" in host:
return "PokeCommunity"
if "hackdex" in host:
return "HackDex"
if "docs.google" in host or "pastebin" in host:
return "Documentation"
if "github" in host:
return "GitHub"
if any(x in host for x in ("mediafire", "mega.nz", "drive.google")):
return "Download"
if "fandom" in host or "wiki" in host:
return "Wiki"
return "Link"
def is_placeholder_link(url: str) -> bool:
parsed = urlparse(url)
host = parsed.netloc.lower()
path = parsed.path.strip("/")
if "duckduckgo.com" in host:
return True
if host == "drive.google.com" and not path:
return True
return False
def normalize_links_section(existing: str, extra_urls: list[str]) -> str:
pairs: list[tuple[str, str]] = []
for line in existing.splitlines():
url_match = re.search(r"(https?://\S+)", line)
if not url_match:
continue
url = url_match.group(1).rstrip(".,)")
label_match = re.match(r"^\s*-\s*([^:]+):", line)
pairs.append(((label_match.group(1).strip() if label_match else link_label(url)), url))
for url in extra_urls:
pairs.append((link_label(url), url))
has_real_source = any(not is_placeholder_link(url) for _, url in pairs)
seen = set()
lines = []
for label, url in pairs:
if has_real_source and is_placeholder_link(url):
continue
if url in seen:
continue
seen.add(url)
lines.append(f"- {label}: {url}")
return "\n".join(lines)
def normalize_features(text: str) -> tuple[str, list[str]]:
lines = []
extracted_urls = []
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
line = re.sub(r"^[-*•➡️\s]+", "", line).strip()
markdown_link = re.fullmatch(r"\[([^\]]+)\]\((https?://[^)]+)\)", line)
if markdown_link:
extracted_urls.append(markdown_link.group(2))
if re.search(r"document|documentation|more information|wiki|download|link", markdown_link.group(1), re.I):
continue
urls = extract_urls(line)
if urls:
extracted_urls.extend(urls)
if re.fullmatch(r"(?:Document|Documentation|More information|Wiki|Download|Link)s?\**:?", line.split("http", 1)[0].strip(), re.I):
continue
line = re.sub(r"\s*https?://\S+", "", line).strip(" -")
line = line.strip("* ")
if not line or re.fullmatch(r"more informations?", line, re.I):
continue
if not line.endswith((".", "!", "?")) and len(line) > 80:
line += "."
lines.append(f"- {line}")
return "\n".join(unique_ordered(lines)), extracted_urls
def ignore_generation_context(text: str, start: int, end: int) -> bool:
window = text[max(0, start - 35) : min(len(text), end + 45)]
if re.search(r"\b(?:dex|national dex|pok[eé]mon|mons|roster|catch|available)\b", window, re.I):
return False
return bool(re.search(r"\b(?:battle|engine|mechanics?|moves?|abilities|standard|style)\b", window, re.I))
def find_generation_max(text: str) -> int | None:
explicit: list[int] = []
if re.search(r"\b(?:all generations|all gens|from all gens|from all generations)\b", text, re.I):
explicit.append(9)
for match in re.finditer(
r"\bgen(?:eration)?s?\s*(\d|i{1,3}|iv|v|vi{0,3}|ix)\s*(?:-||—|to|through|thru|up to|and)\s*(?:gen(?:eration)?s?\s*)?(\d|i{1,3}|iv|v|vi{0,3}|ix)\b",
text,
re.I,
):
if ignore_generation_context(text, match.start(), match.end()):
continue
a = gen_label_to_int(match.group(1))
b = gen_label_to_int(match.group(2))
if a and b:
explicit.append(max(a, b))
for match in re.finditer(r"\b(?:gen(?:eration)?s?|through gen|up to gen)\s*(\d|i{1,3}|iv|v|vi{0,3}|ix)\b", text, re.I):
if ignore_generation_context(text, match.start(), match.end()):
continue
n = gen_label_to_int(match.group(1))
if n:
explicit.append(n)
for count, gen in COUNT_TO_GEN:
if re.search(rf"\b{count}\+?\s+(?:pok[eé]mon|mons|national dex|dex)\b", text, re.I):
explicit.append(gen)
if re.search(rf"\b(?:pok[eé]mon|mons|national dex|dex)\s*(?:up to|through|of)?\s*{count}\+?\b", text, re.I):
explicit.append(gen)
return max(explicit) if explicit else None
def infer_generations(fm: dict, body: str) -> list[str]:
if "generations" in fm:
return list(fm["generations"])
title = fm.get("title", "")
text = f"{title}\n{body}"
max_gen = find_generation_max(text)
replaces_official = re.search(
r"(?:replaces|removes|rids) .{0,80}(?:official\s+)?pok[eé]mon"
r"|replaces .{0,80}with .{0,80}(?:touhou characters|boneka)",
text,
re.I,
)
custom_species_roster = re.search(
r"(?:entirely new|all[- ]new|full new|brand-new|custom)\s+(?:dex|pok[eé]dex|roster|mons|pok[eé]mon)"
r"|(?:dex|pok[eé]dex|roster)\s+full of\s+(?:new|custom)?\s*mons"
r"|(?:over|more than)\s+\d+\s+original\s+fakemon"
r"|\ball\s+\d+\s+boneka\b"
r"|\b(?:boneka|touhou characters?)\s+roster\b",
text,
re.I,
)
pure_custom_roster = fm.get("fakemon") == "Yes" and (replaces_official or custom_species_roster)
regions = [region for region in REGIONS if re.search(rf"\b{region}\b", text, re.I)]
if pure_custom_roster:
return regions
if max_gen is None and not pure_custom_roster:
base = fm.get("base")
max_gen = BASE_MAX_GEN.get(base)
generations = gens_through(max_gen) if max_gen else []
return unique_ordered(generations + regions)
def infer_fakemon(fm: dict, body: str) -> str:
if fm.get("fakemon") in {"Yes", "No"}:
return fm["fakemon"]
text = f"{fm.get('title', '')}\n{body}"
text = re.sub(r"Fakemon\s+\*\*(?:Yes|No)\*\*", "", text, flags=re.I)
text = re.sub(r"\b(?:without|no|not)\s+(?:a\s+)?fakemon(?:\s+roster)?\b", "", text, flags=re.I)
strong = [
r"\bfakemon\b",
r"fan[- ]made pok[eé]mon",
r"original (?:fakemon|monsters)",
r"custom (?:pok[eé]mon|monsters|mons|pokedex|pok[eé]dex)(?!\s+(?:sprites?|cries?))",
r"all[- ]new (?:pok[eé]mon|monsters|pokedex|pok[eé]dex)",
r"\d{2,4}\s+(?:new|original|custom)?\s*(?:fakemon|monsters) designed",
r"replaces .{0,80}official pok[eé]mon",
]
if any(re.search(pattern, text, re.I) for pattern in strong):
return "Yes"
return "No"
def update_callout(body: str, fm: dict) -> str:
generations = ", ".join(fm.get("generations") or [])
extra = []
if generations:
extra.append(f"roster **{generations}**")
if fm.get("fakemon"):
extra.append(f"Fakemon **{fm['fakemon']}**")
if not extra:
return body
lines = body.splitlines()
for idx, line in enumerate(lines):
if line.startswith("> ") and " · base **" in line:
line = re.sub(r"\s*·\s*roster \*\*[^*]+\*\*", "", line)
line = re.sub(r"\s*·\s*Fakemon \*\*(?:Yes|No)\*\*", "", line)
lines[idx] = line + " · " + " · ".join(extra)
return "\n".join(lines)
return body
def standardize_note(path: str) -> tuple[bool, dict]:
fm_text, body, _ = E.read_note(path)
fm = E.parse_fm(fm_text)
before_fm = dict(fm)
before_body = body
sections = note_sections(body)
if fm.get("base") in BAD_BASE_VALUES:
fm["base"] = ""
fm["fakemon"] = infer_fakemon(fm, body)
fm["generations"] = apply_fakemon_generation_tag(
{**fm, "generations": infer_generations(fm, body)},
os.path.splitext(os.path.basename(path))[0],
)
fm["tags"] = E.derive_tags(fm)
if sections.get("Summary"):
cleaned = clean_summary(sections["Summary"])
if cleaned and cleaned != sections["Summary"]:
body = set_section(body, "Summary", cleaned)
extracted_urls: list[str] = []
if sections.get("Features"):
features, extracted_urls = normalize_features(sections["Features"])
if features and features != sections["Features"]:
body = set_section(body, "Features", features)
type_links = E.type_links(fm.get("type") or [])
if type_links:
body = set_section(body, "Type", type_links)
current_sections = note_sections(body)
if current_sections.get("Links") or extracted_urls:
links = normalize_links_section(current_sections.get("Links", ""), extracted_urls)
if links:
body = set_section(body, "Links", links)
body = update_callout(body, fm)
content = f"---\n{E.emit_fm(fm)}\n---\n{body.rstrip()}\n"
changed = before_fm != fm or before_body.rstrip() != body.rstrip()
audit = {
"stem": os.path.splitext(os.path.basename(path))[0],
"fakemon": fm.get("fakemon"),
"generations": fm.get("generations") or [],
"changed": changed,
"had_features": bool(sections.get("Features")),
"feature_urls_moved": len(extracted_urls),
}
return changed, {"content": content, "audit": audit}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--apply", action="store_true")
parser.add_argument("--limit", type=int)
parser.add_argument("--report", default=os.path.join(E.SCRAPE, "standardize-report.json"))
args = parser.parse_args()
paths = [os.path.join(E.HACKS, fn) for fn in sorted(os.listdir(E.HACKS)) if fn.endswith(".md")]
if args.limit:
paths = paths[: args.limit]
changed = 0
audits = []
for path in paths:
did_change, result = standardize_note(path)
audits.append(result["audit"])
if did_change:
changed += 1
if args.apply:
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write(result["content"])
else:
os.makedirs(E.PREVIEW, exist_ok=True)
with open(os.path.join(E.PREVIEW, os.path.basename(path)), "w", encoding="utf-8", newline="\n") as f:
f.write(result["content"])
summary = {
"notes": len(paths),
"changed": changed,
"fakemon": dict(Counter(a["fakemon"] for a in audits)),
"with_generations": sum(1 for a in audits if a["generations"]),
"without_generations": sum(1 for a in audits if not a["generations"]),
"feature_urls_moved": sum(a["feature_urls_moved"] for a in audits),
"audits": audits,
}
os.makedirs(os.path.dirname(args.report), exist_ok=True)
with open(args.report, "w", encoding="utf-8") as f:
json.dump(summary, f, indent=2, ensure_ascii=False)
print(f"standardized {len(paths)} notes; changed={changed}; apply={args.apply}")
print(f"fakemon={summary['fakemon']}")
print(f"with_generations={summary['with_generations']} without_generations={summary['without_generations']}")
print(f"feature_urls_moved={summary['feature_urls_moved']}")
print(f"report={args.report}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+9011 -2961
View File
File diff suppressed because it is too large Load Diff