"""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)