feat: improve RenPy conversion and support MIST runtime
This commit is contained in:
1 parent
f7f7f5b1b3
commit
f9eb5c1212
44 files changed
+2822
-174
No files matched your search
@@ -0,0 +1,161 @@
|
||||
"""Authoring defects of concrete Ren'Py builds that the importer tolerates.
|
||||
|
||||
Some games ship with mistakes in their own scripts (a ``jump`` to a label nobody wrote, ``show screen``
|
||||
of a screen that does not exist). Ren'Py fails at the same place, so does the PGames player, which stops
|
||||
with an explicit error when execution reaches one. The importer reports such defects as warnings instead
|
||||
of refusing the whole game.
|
||||
|
||||
A build is recognised by a fingerprint of its script files (names and sizes, not the folder name), and the
|
||||
list is exact: a message is waived only up to the recorded count, so a new or more frequent problem
|
||||
(a mod, another version) is still refused. Add an entry only after confirming that the defect exists in the
|
||||
original build - never to make an import pass.
|
||||
"""
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_SUFFIXES = {".rpy", ".rpyc", ".rpa", ".rpym", ".rpymc"}
|
||||
|
||||
#: fingerprint -> {"id": ..., "defects": {issue message: maximum count}}
|
||||
BUILDS: dict[str, dict] = {
|
||||
"1d8092ca76924015c64c51fb31ff3ed53b4e8d1026a490648abace41a1664840": {
|
||||
"id": "renpy/mist-1.0.3",
|
||||
"verify": {"scripts.rpa": "1d90ea4cd5970e435aaad7aaa11a32d0a6238aeef8f50ba74226709b74322c02"},
|
||||
# All scripts decompile. These targets are referenced but never declared
|
||||
# in the original scripts.rpa. Do not invent replacement story branches.
|
||||
"defects": {
|
||||
"Не найдена цель перехода: robinspent": 1,
|
||||
"Не найдена цель перехода: breakfastdialog": 1,
|
||||
"Не найдена цель перехода: firstdungeontalk": 1,
|
||||
"Не найдена цель перехода: afterrscene09": 1,
|
||||
"Не найден screen": 1,
|
||||
},
|
||||
},
|
||||
"f5dd2b7cc181ae6147fe0034b613b186de7cbde81e6dedde913ac12bb77d8c1b": {
|
||||
"id": "renpy/what-a-legend-0.7.05",
|
||||
# jumps to labels the author never wrote, and a screen that is shown but not defined
|
||||
"defects": {
|
||||
"Не найдена цель перехода: bg_oc_village_miller_label": 1,
|
||||
"Не найдена цель перехода: bg_oc_cityhall_windows_label": 1,
|
||||
"Не найдена цель перехода: q_meetlady_annebase_label": 1,
|
||||
"Не найдена цель перехода: q_myrtle_swimstart_label": 2,
|
||||
"Не найдена цель перехода: q_myrtle_swimtwo_label": 1,
|
||||
"Не найдена цель перехода: q_gomira2_kevin_pre_inn_label": 1,
|
||||
"Не найдена цель перехода: q_gomira2_kevin_pre_inn_door_label": 1,
|
||||
"Не найден screen": 2,
|
||||
},
|
||||
},
|
||||
"2654666aff7f5657102caf4ea5a992dc39e54eae76340705109d8c7d95229f12": {
|
||||
"id": "renpy/the-fixer-0.3.6.04",
|
||||
# names and labels the author never defined, and menu conditions that are not valid Python
|
||||
"defects": {
|
||||
"Выражение: синтаксическая ошибка в выражении: 'player.willpower <=40 and >20'": 1,
|
||||
"Выражение: синтаксическая ошибка в выражении: 'player.willpower <= 60 and > 40'": 1,
|
||||
"Выражение: синтаксическая ошибка в выражении: 'player.willpower >= 80 and player.desire > 20 and < 80'": 1,
|
||||
"Выражение: синтаксическая ошибка в выражении: 'player.will > 30 and < 60'": 2,
|
||||
"Вызов не поддерживается плеером: can_trigger_events": 25,
|
||||
"Не найдена цель перехода: main_quest_05_preperation_vars": 2,
|
||||
"Вызов не поддерживается плеером: c.pokenips_check": 1,
|
||||
"Вызов не поддерживается плеером: fix_clothes": 1,
|
||||
"Вызов не поддерживается плеером: time_kill": 1,
|
||||
"Не найдена цель перехода: work_clothing_fasion_quest_reject": 1,
|
||||
"Не найдена цель перехода: commercial_area_mall_screen": 7,
|
||||
"Не найдена цель перехода: commercial_area": 2,
|
||||
"Не найдена цель перехода: time_round_to_hour_random": 2,
|
||||
"Вызов не поддерживается плеером: player.force_cover": 2,
|
||||
"Не найдена цель перехода: whore_street_sex_floor_vag_want": 1,
|
||||
"Не найдена цель перехода: bedroom_screen": 6,
|
||||
"Вызов не поддерживается плеером: haven_room_join_chance": 2,
|
||||
"Не найдена цель перехода: highway_local_screen": 4,
|
||||
"Не найдена цель перехода: highway_local_motel_screen": 2,
|
||||
"Не найдена цель перехода: bathroom_screen": 3,
|
||||
"Не найдена цель перехода: residential_area_screen": 1,
|
||||
"Вызов не поддерживается плеером: anabel_name": 1,
|
||||
"Вызов не поддерживается плеером: player.sex_agree": 3,
|
||||
"Не найдена цель перехода: shared_dorm_bedroom": 1,
|
||||
"Не найден screen": 1,
|
||||
},
|
||||
},
|
||||
"c262e5f78d5e356269fdde0fd03ca7f1b85a33b0f84df684ff3d3f1eef274906": {
|
||||
"id": "renpy/desert-stalker-0.21.4",
|
||||
# Italian keyword replacer that reads its dictionary from the file system (os/io/zip): not run in
|
||||
# the browser, the translated strings themselves are still imported
|
||||
"skip": ["tl/italian/replaceText.rpy"],
|
||||
# Replace only the verified save recovery block. It assigns renpy.load at init;
|
||||
# merely waiving diagnostics would leave a broken loader in the browser.
|
||||
"blocks": {
|
||||
"scripts/02_functions.rpy": {
|
||||
"c2ec5e42c25fdf617855db0e68b5441f6417be757cfd8e94e7d1b6304f0239a9":
|
||||
"init python:\n def restore_load(filename):\n renpy.load(filename)\n",
|
||||
},
|
||||
},
|
||||
"defects": {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def fingerprint(game_dir: Path) -> str:
|
||||
"""Stable identity of a build: the relative names and sizes of its script files and archives."""
|
||||
entries = sorted(
|
||||
f"{p.relative_to(game_dir).as_posix().lower()}:{p.stat().st_size}"
|
||||
for p in game_dir.rglob("*") if p.is_file() and p.suffix.lower() in SCRIPT_SUFFIXES
|
||||
and p.relative_to(game_dir).parts[0].lower() not in {"renpy", "common", "cache"})
|
||||
return hashlib.sha256("\n".join(entries).encode()).hexdigest()
|
||||
|
||||
|
||||
def waive_known(data, print_id: str | None) -> list[str]:
|
||||
"""Turn the blockers listed for the build into non-blocking warnings. Returns what was waived."""
|
||||
build = next((b for b in BUILDS.values() if b["id"] == print_id), None)
|
||||
if not build:
|
||||
return []
|
||||
waived = []
|
||||
for message, limit in build["defects"].items():
|
||||
if 0 < data.blockers.get(message, 0) <= limit:
|
||||
del data.blockers[message]
|
||||
data.blocker_examples.pop(message, None)
|
||||
waived.append(message)
|
||||
return waived
|
||||
|
||||
|
||||
def skipped_files(build_id: str | None) -> set[str]:
|
||||
"""Scripts of the build that belong to nothing the game runs (a third-party demo left in the folder)."""
|
||||
build = next((b for b in BUILDS.values() if b["id"] == build_id), None)
|
||||
return {name.lower() for name in build.get("skip", ())} if build else set()
|
||||
|
||||
|
||||
def identify(game_dir: Path) -> str | None:
|
||||
"""The id of a known build, or None."""
|
||||
build = BUILDS.get(fingerprint(game_dir))
|
||||
if build:
|
||||
for relative, digest in build.get("verify", {}).items():
|
||||
path = game_dir / relative
|
||||
if not path.is_file():
|
||||
return None
|
||||
with path.open("rb") as stream:
|
||||
if hashlib.file_digest(stream, "sha256").hexdigest() != digest:
|
||||
return None
|
||||
return build["id"] if build else None
|
||||
|
||||
|
||||
def prepare_source(build_id: str | None, relative: str, source: str) -> str:
|
||||
"""Adapt verified engine integration blocks, preserving source line numbers.
|
||||
|
||||
The digest of each complete block must match as well as the build identity.
|
||||
Unknown/modified code is left intact for the normal strict audit.
|
||||
"""
|
||||
from app.converter.rpy_lexer import parse_tree
|
||||
|
||||
build = next((b for b in BUILDS.values() if b["id"] == build_id), {})
|
||||
patches = build.get("blocks", {}).get(relative.lower(), {})
|
||||
if not patches:
|
||||
return source
|
||||
lines = source.splitlines(keepends=True)
|
||||
tree = parse_tree(source)
|
||||
for index in range(len(tree) - 1, -1, -1):
|
||||
start = tree[index].lineno - 1
|
||||
end = tree[index + 1].lineno - 1 if index + 1 < len(tree) else len(lines)
|
||||
block = "".join(lines[start:end])
|
||||
canonical = block.replace("\r\n", "\n").replace("\r", "\n")
|
||||
replacement = patches.get(hashlib.sha256(canonical.encode()).hexdigest())
|
||||
if replacement is not None:
|
||||
lines[start:end] = [replacement + "\n" * (end - start - replacement.count("\n"))]
|
||||
return "".join(lines)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Third-party Ren'Py libraries that the player implements natively.
|
||||
|
||||
Some community snippets are Python classes written against Ren'Py's render API (``renpy.Displayable``,
|
||||
``renpy.Render``, pygame events). The browser player cannot run that API, so it ships its own class of the
|
||||
same name and the converter drops the game's copy. Everything the converter needs to know about them lives
|
||||
here; add an entry together with the TypeScript implementation (``nativeClasses`` in ``python.ts`` or the
|
||||
displayable in ``screens.ts``), never in the shared converter code.
|
||||
"""
|
||||
|
||||
#: class name -> what the native version offers
|
||||
NATIVE_CLASSES: dict[str, dict] = {
|
||||
"Gallery": {"methods": ("button", "image", "display", "make_button", "Action", "unlock", "unlock_image", "condition")},
|
||||
# parallax picture that drifts against the mouse (screens: ``add TrackCursor("bg.png", 20)``)
|
||||
"TrackCursor": {"methods": ()},
|
||||
# Feniksdev's colour picker; the native class keeps the chosen colour and slot, the game draws its own UI
|
||||
# Ren'Py's own MusicRoom, which games subclass; the player ships the playlist logic (musicroom.ts)
|
||||
"MusicRoom": {"methods": ("add", "is_unlocked", "get_playlist", "Play", "Stop", "TogglePause", "Next",
|
||||
"Previous", "RandomPlay", "SetLoop", "ToggleLoop", "SetSingleTrack",
|
||||
"ToggleSingleTrack", "SetShuffle", "ToggleShuffle", "UnlockAll", "LockAll")},
|
||||
"ColorPicker": {"methods": ("set_color", "save_color", "set_saved_color", "get_color", "swap_to_saved_color")},
|
||||
}
|
||||
|
||||
#: modules those classes import for their event handlers; harmless once the class is replaced
|
||||
NATIVE_IMPORTS = {"pygame"}
|
||||
|
||||
# These image factories are lowered to image IR by displayable_expr, rather than
|
||||
# instantiated in Python. Their render-API classes must not execute at startup.
|
||||
COMPILED_IMAGE_CLASSES = {"TransitionConditionSwitch"}
|
||||
|
||||
NATIVE_METHODS = {m for spec in NATIVE_CLASSES.values() for m in spec["methods"]}
|
||||
Reference in new issue
Block a user