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>
188 lines
10 KiB
Python
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)
|