From 6d78b8b847b7a301950af17cf1efe6c8ac9e1c50 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:29:20 +0300 Subject: [PATCH] feat: build profile for The Lewdest House; panoramas as GPU hints, live2d-only init branches, drag/at syntax The build (fingerprint of its scripts) skips third-party code written against Ren'Py's render API (colour picker demo, Kinetic Text Tags, cps_preview) and waives its one missing test screen. Converter: xpan/ypan/matrixtransform/matrixanchor are skipped like other GPU hints, `if renpy.has_live2d()` in an init block keeps only its else branch, `crop(...)xpos` and `crop (1, 2, 3, 4)` parse, `drag` is a screen container, `at icon["at"]` becomes a dynamic entry, and the whitelist gains Matrix, Move, absolute and the no-op helpers. The game now passes the strict check and converts fully (3272 files, 288 MB). Co-Authored-By: Claude Sonnet 5.5 --- backend/app/converter/atl.py | 5 ++++- backend/app/converter/compatibility.py | 4 +++- backend/app/converter/rpy_helpers.py | 28 ++++++++++++++++++++++---- backend/app/converter/rpy_parser.py | 20 +++++++++++++++++- backend/app/converter/screens.py | 12 ++++++++--- backend/app/games/renpy_builds.py | 10 +++++++++ backend/app/games/renpy_native.py | 2 ++ backend/tests/test_renpy_universal.py | 12 +++++++++++ backend/tests/test_screens.py | 11 ++++++++++ 9 files changed, 94 insertions(+), 10 deletions(-) diff --git a/backend/app/converter/atl.py b/backend/app/converter/atl.py index 729aadc..ca53686 100644 --- a/backend/app/converter/atl.py +++ b/backend/app/converter/atl.py @@ -43,7 +43,7 @@ LEADING_ZERO = re.compile(r"(? 1 and (WARPER.match(parts[0]) or parts[0] == "pause") and parts[1].startswith("("): parts = [parts[0], *self.split_bracket(parts[1]), *parts[2:]] # `ease (0.1)xpos 0.5` first = parts[0] diff --git a/backend/app/converter/compatibility.py b/backend/app/converter/compatibility.py index 3417870..3f9152f 100644 --- a/backend/app/converter/compatibility.py +++ b/backend/app/converter/compatibility.py @@ -120,7 +120,9 @@ CALLS |= {"renpy.start_predict", "renpy.stop_predict", "renpy.start_predict_scre "renpy.ui.imagebutton", "renpy.return_statement"} from app.games.renpy_native import NATIVE_CLASSES, NATIVE_METHODS # noqa: E402 CALLS |= set(NATIVE_CLASSES) -CALLS |= {"type", "id", "vars", "Flatten", "LiveComposite", "Composite", "defaultdict", "OrderedDict", "deepcopy", +CALLS |= {"Matrix", "renpy.has_live2d", "renpy.fetch", "renpy.display.draw.kill_textures", + "renpy.display.render.free_memory", "cache.get_total_size"} +CALLS |= {"Move", "absolute", "type", "id", "vars", "Flatten", "LiveComposite", "Composite", "defaultdict", "OrderedDict", "deepcopy", "hashlib.md5", "hashlib.sha256", "uuid.uuid4", "time.monotonic", "re.compile", "math.isfinite", "renpy.log", "renpy.write_log", "renpy.get_filename_line", "renpy.has_image", "renpy.has_screen", "renpy.list_files", "renpy.get_mouse_pos", "renpy.jump_out_of_context", "renpy.random.seed", "random.seed"} diff --git a/backend/app/converter/rpy_helpers.py b/backend/app/converter/rpy_helpers.py index 9b684e2..9c75050 100644 --- a/backend/app/converter/rpy_helpers.py +++ b/backend/app/converter/rpy_helpers.py @@ -81,7 +81,7 @@ def _static_props(kw: dict[str, Expr]) -> dict[str, Any]: def _atl_props(text: str) -> dict[str, Any]: """Extract static `prop value` pairs from one ATL line, ignoring timing.""" - tokens = text.replace(",", " ").split() + tokens = _words(text) props: dict[str, Any] = {} i = 0 while i < len(tokens) - 1: @@ -89,11 +89,12 @@ def _atl_props(text: str) -> dict[str, Any]: if key in POSITION_PROPS: raw = tokens[i + 1] try: - props[key] = ast.literal_eval(raw) + value = ast.literal_eval(raw) + props[key] = list(value) if isinstance(value, tuple) else value except (ValueError, SyntaxError): - if raw.startswith("(") and i + 2 < len(tokens): + if not raw.startswith("(") and i + 2 < len(tokens): # `pos 0.5 0.3` try: - props[key] = list(ast.literal_eval(raw + "," + tokens[i + 2])) + props[key] = [ast.literal_eval(raw), ast.literal_eval(tokens[i + 2])] i += 1 except (ValueError, SyntaxError): pass @@ -103,6 +104,25 @@ def _atl_props(text: str) -> dict[str, Any]: return props +def _words(text: str) -> list[str]: + """Words split on whitespace and commas outside brackets: `crop (1, 2, 3, 4)` keeps its tuple whole.""" + out, cur, depth = [], [], 0 + for ch in text: + if ch in "([{": + depth += 1 + elif ch in ")]}": + depth -= 1 + if (ch.isspace() or ch == ",") and depth == 0: + if cur: + out.append("".join(cur)) + cur = [] + else: + cur.append(ch) + if cur: + out.append("".join(cur)) + return out + + def _split_top(source: str) -> list[str]: parts, depth, cur = [], 0, [] for ch in source: diff --git a/backend/app/converter/rpy_parser.py b/backend/app/converter/rpy_parser.py index 788953f..c6d8bfd 100644 --- a/backend/app/converter/rpy_parser.py +++ b/backend/app/converter/rpy_parser.py @@ -152,6 +152,7 @@ class ScriptData: self.warning_examples[kind] = where +LIVE2D_IF = re.compile(r"^\s*if\s+renpy\.has_live2d\(\)(?:\s+and\s.*)?:\s*$") TRANSLATED_SPEAKER = re.compile(r"""^_{1,2}\(\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*\)(?=\s+["'])""") @@ -468,7 +469,7 @@ class RpyParser: c.keyword("python") self.init_python(line.block_source(), line) return [] - for child in line.block: + for child in self.fold_live2d(line.block): for node in self.statement(child, top_level=True): if node["op"] == "exec": self.d.init_code.extend(node["code"]) @@ -476,6 +477,23 @@ class RpyParser: self.d.warn("Оператор сцены внутри init", self.where(child)) return [] + @staticmethod + def fold_live2d(children: list[Line]) -> list[Line]: + """`if renpy.has_live2d() and ...:` is false in the player (it has no Live2D), so only its `else` counts.""" + out: list[Line] = [] + i = 0 + while i < len(children): + child = children[i] + if LIVE2D_IF.match(child.text): + i += 1 + if i < len(children) and children[i].text.strip().startswith("else"): + out.extend(RpyParser.fold_live2d(children[i].block)) + i += 1 + continue + out.append(child) + i += 1 + return out + def st_python(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("python") diff --git a/backend/app/converter/screens.py b/backend/app/converter/screens.py index dcec270..a314f99 100644 --- a/backend/app/converter/screens.py +++ b/backend/app/converter/screens.py @@ -30,7 +30,8 @@ Node = dict[str, Any] # Calls whose first argument names a screen to open OPENERS = {"Show", "ShowTransient", "ToggleScreen", "Hide", "show_screen", "call_screen", "hide_screen"} -CONTAINERS = {"fixed", "frame", "window", "vbox", "hbox", "grid", "vpgrid", "viewport", "button", "side", "nearrect"} +CONTAINERS = {"fixed", "frame", "window", "vbox", "hbox", "grid", "vpgrid", "viewport", "button", "side", "nearrect", + "drag"} LEAVES = {"text", "label", "textbutton", "imagebutton", "add", "image", "null", "bar", "vbar", "mousearea"} POSITIONAL = {"side": 1, "text": 1, "label": 1, "textbutton": 1, "add": 1, "image": 1, "grid": 2} ATL_WORDS = {"pause", "repeat", "parallel", "choice", "block", "contains", "function", "time"} @@ -48,11 +49,12 @@ kerning layout first_indent min_width antialias xalign yalign justify hyperlink_ BEHAVIOUR = set("""action alternate hovered unhovered sensitive tooltip focus_mask mouse hover_sound activate_sound focus keysym alt id modal zorder tag layer variant style style_prefix at value range adjustment changed mousewheel draggable scrollbars xinitial yinitial child_size edgescroll arrowkeys -text_style properties substitute prefix suffix repeat style_group prefer_top preferred_side""".split()) +text_style properties substitute prefix suffix repeat style_group prefer_top preferred_side +drag_name draggable droppable drag_handle drag_joined drag_offscreen""".split()) # Scrolling widgets share state through adjustments; scrollbars are cosmetic in the browser IGNORED_PREFIXES = ("scrollbar_", "vscrollbar_", "bar_", "side_", "thumb_", "slider_", "vslider_", "left_gutter", "right_gutter", "top_gutter", "bottom_gutter") -IGNORED = {"base_bar", "subpixel", "roll_forward", "xadjustment", "yadjustment", "pagekeys", "unscrollable", "arrowkeys", "edgescroll", "draggable", +IGNORED = {"base_bar", "subpixel", "roll_forward", "xadjustment", "yadjustment", "pagekeys", "unscrollable", "arrowkeys", "edgescroll", "child_size", "mouse", "alt", "properties", "substitute", "prefix", "suffix", "keysym"} STATE_PREFIXES = ("idle", "hover", "insensitive", "selected", "selected_idle", "selected_hover", "selected_insensitive") @@ -417,6 +419,10 @@ class ScreenParser: if cond is not None: return {"choose": {"c": cond, "then": self.at_ref(ast.unparse(tree.body), line), "else": self.at_ref(ast.unparse(tree.orelse), line)}} + if re.fullmatch(r"[A-Za-z_]\w*(?:\.\w+)*\[[^\]]+\]|[A-Za-z_]\w*\.\w+", text): # `icon["at"]`, `item.at` + dynamic = self.expr(text, line) + if dynamic is not None: + return {"dyn": dynamic} return self.transform_ref(source, line) def properties(self, words: list[str], line: Line, lenient: bool = False) -> dict[str, Any]: diff --git a/backend/app/games/renpy_builds.py b/backend/app/games/renpy_builds.py index 28970ec..189393c 100644 --- a/backend/app/games/renpy_builds.py +++ b/backend/app/games/renpy_builds.py @@ -90,6 +90,16 @@ BUILDS: dict[str, dict] = { }, "defects": {}, }, + "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) diff --git a/backend/app/games/renpy_native.py b/backend/app/games/renpy_native.py index 40ffbbf..a42ca4e 100644 --- a/backend/app/games/renpy_native.py +++ b/backend/app/games/renpy_native.py @@ -12,6 +12,8 @@ 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": ()}, + # the Preferences screen's text-speed preview (the game's copy draws slowly through renpy.Render) + "PreviewSlowText": {"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", diff --git a/backend/tests/test_renpy_universal.py b/backend/tests/test_renpy_universal.py index 467569f..9f2057d 100644 --- a/backend/tests/test_renpy_universal.py +++ b/backend/tests/test_renpy_universal.py @@ -223,3 +223,15 @@ def test_translated_speaker_name_and_capitalised_parameterised_transform(): say = data.modules["t.rpy"][0]["body"][0] assert say["op"] == "say" and say["what"] == "Hello there" assert not data.blockers + + +def test_init_if_has_live2d_keeps_only_the_else_branch_and_crop_call_glued_to_a_property(): + data = parse('init:\n if renpy.has_live2d() and persistent.on:\n image a = "live.png"\n else:\n' + ' image b = "still.png"\nlabel start:\n show a1 as p:\n crop(1,2,3,4)xpos 5\n return\n') + assert "b" in data.images and "a" not in data.images + assert not any("Оператор сцены" in m for m in data.blockers) + + +def test_static_atl_line_with_a_spaced_crop_tuple(): + data = parse('image plap:\n "base"\n crop (1697,54, 172, 94)\nimage slurp:\n "base"\n crop (1, 2, 3, 4) xanchor 0.5\n') + assert not data.blockers diff --git a/backend/tests/test_screens.py b/backend/tests/test_screens.py index 36fc10d..c415333 100644 --- a/backend/tests/test_screens.py +++ b/backend/tests/test_screens.py @@ -235,3 +235,14 @@ def test_atl_pause_with_brackets(): steps = data.atl["t"]["steps"] if "t" in data.atl else data.transforms["t"] assert not data.blockers assert [(s["op"], s["t"]) for s in steps] == [("move", 1.0), ("pause", 1.5), ("move", 1.0)] + + +def test_dynamic_at_value_and_draggable_box(): + data = parse('transform spin:\n rotate 5\nscreen s(icon):\n imagebutton:\n idle "a" at icon["at"]\n' + ' drag:\n drag_name "box"\n draggable True\n text "x"\n' + 'label start:\n call screen s({"at": spin})\n') + audit_ir(data) + require_supported_scripts(data) + body = data.screens["s"]["body"] + assert body[0]["props"]["at"][0]["dyn"]["k"] == "sub" + assert body[1]["w"] == "drag" and "draggable" in body[1]["props"]