Compare commits
10
Commits
52ee1b98a1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79ed715dbb | ||
|
|
217ed639ca | ||
|
|
f97c29e90c | ||
|
|
4ed6dfc880 | ||
|
|
fac5664f93 | ||
|
|
32fd6eda06 | ||
|
|
9351f8b415 | ||
|
|
44183fb0fb | ||
|
|
809600a04d | ||
|
|
ae56d101b7 |
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]]",
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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, ""):
|
||||
|
||||
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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.'
|
||||
@@ -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"
|
||||
@@ -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)."
|
||||
@@ -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))
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -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()
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user