Files
pgames-manager/backend/app/games/renpy_builds.py
T
mrleo1nidandClaude Sonnet 5.5 0822c76321
CI / frontend (push) Failing after 22s
CI / backend (push) Failing after 37s
feat: build profile for The Only Doe 0.05 that fixes an assignment in a menu condition
sleep.rpy tests `... before_sleeping=2` (assignment) in an elif; sleep.rpyc holds the same text, so
Ren'Py cannot compile that branch either. Builds can now carry exact-line patches pinned by the
SHA-256 of the line and the build fingerprint; this one restores `==2`. Anything else stays strict.

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

188 lines
10 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": {},
},
"bb19a33af00324fa027a629c052aee885e72f1b7bee7074403a5b9e20c69e2d8": {
"id": "renpy/the-only-doe-0.05",
# 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 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)