cave-story-randomizer/caver/patcher.py

219 lines
7.4 KiB
Python
Raw Normal View History

2024-12-18 01:34:06 +00:00
from __future__ import annotations
import json
2021-12-01 04:37:50 +00:00
import logging
2024-12-18 01:34:06 +00:00
import platform as pl
2021-12-01 04:37:50 +00:00
import shutil
2021-12-08 06:42:20 +00:00
import sys
2024-12-18 01:34:06 +00:00
import textwrap
import typing
from enum import Enum
from pathlib import Path
from uuid import UUID
2021-12-08 06:09:45 +00:00
import pre_edited_cs
2024-12-18 01:34:06 +00:00
from randovania_lupa import LuaRuntime # type: ignore
from caver.schema.validator_with_default import DefaultValidatingDraft7Validator
2021-12-01 04:37:50 +00:00
2024-12-18 01:34:06 +00:00
LuaFile = typing.Any
2021-12-01 04:37:50 +00:00
2024-12-18 01:34:06 +00:00
if typing.TYPE_CHECKING:
from collections.abc import Callable
from caver.schema import (
CaverData,
CaverdataMaps,
CaverdataOtherTsc,
EventNumber,
MapName,
)
2021-12-01 04:37:50 +00:00
2021-12-06 08:20:16 +00:00
class CaverException(Exception):
pass
2024-12-18 01:34:06 +00:00
class CSPlatform(Enum):
FREEWARE = "freeware"
TWEAKED = "tweaked"
2024-12-18 01:34:06 +00:00
2021-12-08 06:42:20 +00:00
def get_path() -> Path:
if getattr(sys, "frozen", False):
file_dir = Path(getattr(sys, "_MEIPASS"))
else:
file_dir = Path(__file__).parent.parent
return file_dir.joinpath("caver")
2021-12-08 06:42:20 +00:00
2024-12-18 01:34:06 +00:00
def validate(patch_data: dict) -> None:
with Path(__file__).parent.joinpath("schema/schema.json").open() as f:
schema = json.load(f)
DefaultValidatingDraft7Validator(schema).validate(patch_data)
def patch_files(
patch_data: CaverData, output_dir: Path, platform: CSPlatform, progress_update: Callable[[str, float], None]
) -> None:
progress_update("Validating schema...", -1)
validate(typing.cast(dict, patch_data))
2021-12-08 06:42:20 +00:00
progress_update("Copying base files...", -1)
ensure_base_files_exist(platform, output_dir)
2021-12-01 04:37:50 +00:00
2023-10-11 06:56:21 +00:00
total = len(patch_data["maps"].keys()) + len(patch_data["other_tsc"].keys()) + 3
2021-12-01 07:28:01 +00:00
2021-12-08 06:42:20 +00:00
lua_file = get_path().joinpath("tsc_file.lua").read_text()
2024-12-18 01:34:06 +00:00
TscFile = typing.cast(LuaFile, LuaRuntime().execute(lua_file))
2021-12-08 06:42:20 +00:00
2021-12-01 07:28:01 +00:00
for i, (mapname, mapdata) in enumerate(patch_data["maps"].items()):
2024-12-18 01:34:06 +00:00
progress_update(f"Patching {mapname}...", i / total)
2021-12-01 04:37:50 +00:00
patch_map(mapname, mapdata, TscFile, output_dir)
2023-10-11 06:56:21 +00:00
2021-12-10 09:05:51 +00:00
for filename, scripts in patch_data["other_tsc"].items():
i += 1
2024-12-18 01:34:06 +00:00
progress_update(f"Patching {filename}.tsc...", i / total)
2021-12-10 09:05:51 +00:00
patch_other(filename, scripts, TscFile, output_dir)
i += 1
2024-12-18 01:34:06 +00:00
progress_update("Copying MyChar...", i / total)
patch_mychar(patch_data["mychar"], output_dir, platform is CSPlatform.TWEAKED)
2021-12-01 04:37:50 +00:00
2021-12-10 09:05:51 +00:00
i += 1
2024-12-18 01:34:06 +00:00
progress_update("Copying hash...", i / total)
2021-12-01 04:37:50 +00:00
patch_hash(patch_data["hash"], output_dir)
2023-10-11 06:56:21 +00:00
i += 1
2024-12-18 01:34:06 +00:00
progress_update("Copying UUID...", i / total)
2023-10-11 06:56:21 +00:00
patch_uuid(patch_data["uuid"], output_dir)
if platform == CSPlatform.TWEAKED:
if pl.system() == "Linux":
output_dir.joinpath("CSTweaked.exe").unlink()
else:
output_dir.joinpath("CSTweaked").unlink()
2024-12-18 01:34:06 +00:00
def ensure_base_files_exist(platform: CSPlatform, output_dir: Path) -> None:
2021-12-01 04:37:50 +00:00
internal_copy = pre_edited_cs.get_path()
2024-12-18 21:04:11 +00:00
with internal_copy.joinpath("data", "version.txt").open() as version_file:
2024-12-18 01:25:32 +00:00
latest_version = version_file.readline()
2024-12-18 21:04:11 +00:00
version = output_dir.joinpath("data", "version.txt")
2024-12-18 01:25:32 +00:00
current_version = "v0.0.0.0"
if version.exists():
with version.open() as version_file:
current_version = version_file.readline()
keep_existing_files = current_version >= latest_version
2021-12-01 04:37:50 +00:00
2024-12-18 01:34:06 +00:00
def should_ignore(path: str, names: list[str]) -> list[str]:
2021-12-08 06:42:20 +00:00
base = ["__init__.py", "__pycache__", "ScriptSource", "__pyinstaller"]
2021-12-01 07:28:01 +00:00
if keep_existing_files:
p = Path(path)
2024-12-18 01:34:06 +00:00
base.extend(
[str(p.joinpath(name)) for name in names if p.joinpath(name).exists() and p.joinpath(name).is_file()]
)
2021-12-01 07:28:01 +00:00
return base
2023-10-11 06:56:21 +00:00
2021-12-06 08:20:16 +00:00
try:
shutil.copytree(internal_copy.joinpath(platform.value), output_dir, ignore=should_ignore, dirs_exist_ok=True)
2024-12-18 01:34:06 +00:00
shutil.copytree(
internal_copy.joinpath("data"), output_dir.joinpath("data"), ignore=should_ignore, dirs_exist_ok=True
)
2021-12-06 08:20:16 +00:00
except shutil.Error:
2024-12-18 01:34:06 +00:00
raise CaverException(
"Error copying base files. Ensure the directory is not read-only, and that Doukutsu.exe is closed"
)
2021-12-01 07:28:01 +00:00
output_dir.joinpath("data", "Plaintext").mkdir(exist_ok=True)
2021-12-01 04:37:50 +00:00
2024-12-18 01:34:06 +00:00
def patch_map(mapname: MapName, mapdata: CaverdataMaps, TscFile: LuaFile, output_dir: Path) -> None:
2021-12-01 04:37:50 +00:00
mappath = output_dir.joinpath("data", "Stage", f"{mapname}.tsc")
2021-12-01 07:28:01 +00:00
tsc_file = TscFile.new(TscFile, mappath.read_bytes(), logging.getLogger("caver"))
2021-12-01 04:37:50 +00:00
for event, script in mapdata["pickups"].items():
2021-12-01 08:44:05 +00:00
TscFile.placeScriptAtEvent(tsc_file, script, event, mapname)
2023-10-11 06:56:21 +00:00
2021-12-01 04:37:50 +00:00
for event, song in mapdata["music"].items():
TscFile.placeSongAtCue(tsc_file, song["song_id"], event, song["original_id"], mapname)
2023-10-11 06:56:21 +00:00
2021-12-01 04:37:50 +00:00
for event, script in mapdata["entrances"].items():
2024-12-18 01:34:06 +00:00
needle = "<EVE...." # TODO: create a proper pattern
2021-12-01 08:44:05 +00:00
TscFile.placeScriptAtEvent(tsc_file, script, event, mapname, needle)
2023-10-11 06:56:21 +00:00
2021-12-01 08:44:05 +00:00
for event, hint in mapdata["hints"].items():
2021-12-06 08:20:16 +00:00
script = create_hint_script(hint["text"], hint.get("facepic", "0000") != "0000", hint.get("ending", "<END"))
2021-12-01 08:44:05 +00:00
TscFile.placeScriptAtEvent(tsc_file, script, event, mapname)
2021-12-01 04:37:50 +00:00
2021-12-01 07:28:01 +00:00
chars = TscFile.getText(tsc_file).values()
mappath.write_bytes(bytes(chars))
output_dir.joinpath("data", "Plaintext", f"{mapname}.txt").write_text(TscFile.getPlaintext(tsc_file))
2021-12-01 04:37:50 +00:00
2024-12-18 01:34:06 +00:00
def patch_other(
filename: MapName, scripts: dict[EventNumber, CaverdataOtherTsc], TscFile: LuaFile, output_dir: Path
) -> None:
2021-12-10 09:05:51 +00:00
filepath = output_dir.joinpath("data", f"{filename}.tsc")
tsc_file = TscFile.new(TscFile, filepath.read_bytes(), logging.getLogger("caver"))
for event, script in scripts.items():
2021-12-11 01:14:22 +00:00
TscFile.placeScriptAtEvent(tsc_file, script["script"], event, filename, script.get("needle", "<EVE...."))
2023-10-11 06:56:21 +00:00
2021-12-10 09:05:51 +00:00
chars = TscFile.getText(tsc_file).values()
filepath.write_bytes(bytes(chars))
output_dir.joinpath("data", "Plaintext", f"{filename}.txt").write_text(TscFile.getPlaintext(tsc_file))
2024-12-18 01:34:06 +00:00
def patch_mychar(mychar: str | None, output_dir: Path, add_upscale: bool) -> None:
2021-12-01 04:37:50 +00:00
if mychar is None:
return
mychar_img = Path(mychar).read_bytes()
output_dir.joinpath("data", "MyChar.bmp").write_bytes(mychar_img)
if add_upscale:
mychar_name = Path(mychar).name
mychar_up_img = Path(mychar).parent.joinpath("2x", mychar_name).read_bytes()
output_dir.joinpath("data", "sprites_up", "MyChar.bmp").write_bytes(mychar_up_img)
2024-12-18 01:34:06 +00:00
def patch_hash(hash: list[int], output_dir: Path) -> None:
2021-12-01 04:37:50 +00:00
hash_strings = [f"{num:04d}" for num in hash]
hash_string = ",".join(hash_strings)
2021-12-01 08:44:05 +00:00
output_dir.joinpath("data", "hash.txt").write_text(hash_string)
2024-12-18 01:34:06 +00:00
def patch_uuid(uuid: str, output_dir: Path) -> None:
uuid = f"{{{UUID(uuid)}}}"
2023-10-11 06:56:21 +00:00
output_dir.joinpath("data", "uuid.txt").write_text(uuid)
2024-12-18 01:34:06 +00:00
def wrap_msg_text(text: str, facepic: bool, *, ending: str = "<NOD", max_text_boxes: int | None = 1) -> str:
2021-12-01 08:44:05 +00:00
hard_limit = 35
2021-12-06 08:20:16 +00:00
msgbox_limit = 26 if facepic else hard_limit
2023-10-11 06:56:21 +00:00
max_lines = max_text_boxes * 3 if max_text_boxes is not None else None
lines = textwrap.wrap(text, width=msgbox_limit, max_lines=max_lines)
2022-01-12 03:54:58 +00:00
text = ""
2024-12-18 01:34:06 +00:00
for i, line in enumerate(lines):
text += line
if i < len(lines) - 1:
2023-10-11 06:56:21 +00:00
if i % 3 == 2:
text += "<NOD"
2024-12-18 01:34:06 +00:00
if len(line) != hard_limit:
2023-10-11 06:56:21 +00:00
text += "\r\n"
2023-10-13 04:04:14 +00:00
text += ending
2023-10-11 06:56:21 +00:00
return text
2024-12-18 01:34:06 +00:00
2023-10-11 06:56:21 +00:00
def create_hint_script(text: str, facepic: bool, ending: str) -> str:
"""
2024-12-18 01:34:06 +00:00
A desperate attempt to generate valid <MSG text.
Fills one text box (up to three lines). Attempts to wrap words elegantly.
2023-10-11 06:56:21 +00:00
"""
2023-10-13 04:04:14 +00:00
return f"<PRI<MSG<TUR{wrap_msg_text(text, facepic, ending=ending)}"