feat: build profile for The Lewdest House; panoramas as GPU hints, live2d-only init branches, drag/at syntax
CI / frontend (push) Failing after 15s
CI / backend (push) Failing after 33s

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 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-26 04:29:20 +03:00
1 parent 353cd5e49e
commit 6d78b8b847
9 files changed
+94 -10

No files matched your search

+4 -1
View File
@@ -43,7 +43,7 @@ LEADING_ZERO = re.compile(r"(?<![\w.])0+(\d)(?![\d.]*[\w.])")
SPLIT_SIGN = re.compile(r"^([a-z_]+)(-[\d.]+)$")
IGNORED = {"subpixel", "animation", "around", "alignaround", "rotate_pad"} # no visible effect in the browser
# Purely graphical GPU hints; dropping them changes how a picture is drawn, never what happens
COSMETIC = ("transform_anchor", "shader", "mesh", "gl_", "u_", "blend", "additive", "perspective", "point_to", "xrotate", "yrotate",
COSMETIC = ("xpan", "ypan", "matrixtransform", "matrixanchor", "transform_anchor", "shader", "mesh", "gl_", "u_", "blend", "additive", "perspective", "point_to", "xrotate", "yrotate",
"zrotate", "zpos", "zzoom", "delay", "events", "fit", "nearest", "fps", "show_cancels_hide")
NAMED_POSITIONS = {
"left": {"xpos": 0.0, "xanchor": 0.0}, "right": {"xpos": 1.0, "xanchor": 1.0},
@@ -274,6 +274,9 @@ class AtlParser:
break
glued = [parts[0][at + 1:]] if parts[0][at + 1:] else [] # `ease(0.1)rotate 5`
parts = [called[1], parts[0][called.end():at], *glued, *parts[1:]]
if glued_call := re.fullmatch(r"([a-z_]+)(\(.*\))([a-z_]+)", parts[0]): # `crop(1,2,3,4)xpos 5`
if glued_call[1] in POSITION_PROPS:
parts = [glued_call[1], glued_call[2], glued_call[3], *parts[1:]]
if len(parts) > 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]
+3 -1
View File
@@ -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"}
+24 -4
View File
@@ -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:
+19 -1
View File
@@ -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")
+9 -3
View File
@@ -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]:
+10
View File
@@ -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)
+2
View File
@@ -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",
+12
View File
@@ -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
+11
View File
@@ -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"]