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 <noreply@anthropic.com>
This commit is contained in:
1 parent
353cd5e49e
commit
6d78b8b847
9 files changed
+94
-10
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user