Files
pgames-manager/backend/app/games/renpy_builds.py
T
mrleo1nidandClaude Sonnet 5.5 f2fcd05467 feat: build profiles for goodluke 1.2 and SaintlyCorruption 0.13
goodluke skips the author's developer tools (save export, scene browser and launcher, state panel, seed
tool: they read files and start programs, and nothing outside atlas/dev calls them) and the outline
shader demo, and waives two defects of the original: a jump to the label composer_scene that was never
written, and a call to a debug helper that is not defined and sits behind a flag that is False.
SaintlyCorruption needs only its fingerprint. Both now pass the strict check and convert fully.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 17:28:13 +03:00

231 lines
13 KiB
Python

"""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": {},
},
"0c31b84adcee03c53ffdd8ff7a8b6c1fb78ac020b35b45b12d6686f8f9c006b9": {
"id": "renpy/saintly-corruption-0.13",
"defects": {},
},
"383ef4106315471b7fdd8b507812ba0c63ff216c78391f34118fb98be059d4ae": {
"id": "renpy/goodluke-1.2",
# The author's developer tools (hidden behind hotkeys that are off in the build): save export, scene browser and
# launcher, state panel and seed tool. Nothing outside atlas/dev calls them (map_override and quest_trace,
# which the game does call, stay); they read files and run programs, which the browser cannot do.
"skip": ["atlas/dev/save_export.rpy", "atlas/dev/scene_browser.rpy", "atlas/dev/scene_launch.rpy",
"atlas/dev/state_panel.rpy", "atlas/dev/dev_seeds.rpy",
# a sample of the outline shader (demo labels and a demo screen); nothing refers to it
"atlas/effects/shaders/outline.rpy"],
# a jump to a label the author never wrote, and a debug overlay's helper that is not defined anywhere (the call
# sits behind debug_bg_overlay, which is False)
"defects": {"Не найдена цель перехода: composer_scene": 1,
"Вызов не поддерживается плеером: atlas_assets_dbg_bg": 1},
},
"91c62b359d5a0de6fe38630980fb1a4857fa3db1746d2d4240816d24578ccc51": {
"id": "renpy/the-lewdest-house",
# Third-party code written against Ren'Py's render API. The colour picker and its examples are a demo the
# game never calls; Kinetic Text Tags is replaced by the player's own {bt} {sc} {fi} {rotat} (chaos, swap,
# move, omega and para are shown as plain text); PreviewSlowText is a native class (renpy_native.py).
"skip": ["renpy tools/color_picker.rpy", "renpy tools/color_picker_examples.rpy",
"script/engine/kinetic_text_tags.rpy", "script/engine/cps_preview.rpy"],
# open_world_test.rpy shows a screen the author never wrote (a test left in the build)
"defects": {"Не найден screen": 1},
},
"bb19a33af00324fa027a629c052aee885e72f1b7bee7074403a5b9e20c69e2d8": {
"id": "renpy/the-only-doe-0.05",
# The author left a text note instead of a picture; Ren'Py cannot load it either (there is an .svg next to it)
"placeholders": {"gui/journal_icon.png": "4b45bd642d6e43c85dabb5e8b0df3e52e628cde7640519af1a64751750f6b711"},
# One menu branch tests `... before_sleeping=2` (assignment), which Ren'Py cannot compile either:
# sleep.rpyc holds the same text. The port corrects the operator; the digest pins the exact line.
"lines": {
"sleep.rpy": {
"fa092f95dd51401da31285c68c7c4fa9ad9ae4a5dd8c1a1582192f32b1e650cf":
' elif wake_up_after_sex=="spitroast" and number_of_people_had_sex_with_before_sleeping==2:',
},
},
"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 placeholder_files(build_id: str | None, game_dir: Path) -> set[Path]:
"""Files of the build that carry text instead of media; only an exact SHA-256 match is skipped."""
build = next((b for b in BUILDS.values() if b["id"] == build_id), {})
found = set()
for relative, digest in build.get("placeholders", {}).items():
path = game_dir / relative
if path.is_file():
with path.open("rb") as stream:
if hashlib.file_digest(stream, "sha256").hexdigest() == digest:
found.add(path)
return found
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 _patch_lines(source: str, patches: dict[str, str]) -> str:
"""Replace single lines whose exact text (without the line ending) matches a recorded SHA-256."""
if not patches:
return source
lines = source.splitlines(keepends=True)
for index, line in enumerate(lines):
text = line.rstrip("\r\n")
replacement = patches.get(hashlib.sha256(text.encode()).hexdigest())
if replacement is not None:
lines[index] = replacement + line[len(text):]
return "".join(lines)
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), {})
source = _patch_lines(source, build.get("lines", {}).get(relative.lower(), {}))
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)