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.]+)$")
|
SPLIT_SIGN = re.compile(r"^([a-z_]+)(-[\d.]+)$")
|
||||||
IGNORED = {"subpixel", "animation", "around", "alignaround", "rotate_pad"} # no visible effect in the browser
|
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
|
# 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")
|
"zrotate", "zpos", "zzoom", "delay", "events", "fit", "nearest", "fps", "show_cancels_hide")
|
||||||
NAMED_POSITIONS = {
|
NAMED_POSITIONS = {
|
||||||
"left": {"xpos": 0.0, "xanchor": 0.0}, "right": {"xpos": 1.0, "xanchor": 1.0},
|
"left": {"xpos": 0.0, "xanchor": 0.0}, "right": {"xpos": 1.0, "xanchor": 1.0},
|
||||||
@@ -274,6 +274,9 @@ class AtlParser:
|
|||||||
break
|
break
|
||||||
glued = [parts[0][at + 1:]] if parts[0][at + 1:] else [] # `ease(0.1)rotate 5`
|
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:]]
|
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("("):
|
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`
|
parts = [parts[0], *self.split_bracket(parts[1]), *parts[2:]] # `ease (0.1)xpos 0.5`
|
||||||
first = parts[0]
|
first = parts[0]
|
||||||
|
|||||||
@@ -120,7 +120,9 @@ CALLS |= {"renpy.start_predict", "renpy.stop_predict", "renpy.start_predict_scre
|
|||||||
"renpy.ui.imagebutton", "renpy.return_statement"}
|
"renpy.ui.imagebutton", "renpy.return_statement"}
|
||||||
from app.games.renpy_native import NATIVE_CLASSES, NATIVE_METHODS # noqa: E402
|
from app.games.renpy_native import NATIVE_CLASSES, NATIVE_METHODS # noqa: E402
|
||||||
CALLS |= set(NATIVE_CLASSES)
|
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",
|
"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.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"}
|
"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]:
|
def _atl_props(text: str) -> dict[str, Any]:
|
||||||
"""Extract static `prop value` pairs from one ATL line, ignoring timing."""
|
"""Extract static `prop value` pairs from one ATL line, ignoring timing."""
|
||||||
tokens = text.replace(",", " ").split()
|
tokens = _words(text)
|
||||||
props: dict[str, Any] = {}
|
props: dict[str, Any] = {}
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(tokens) - 1:
|
while i < len(tokens) - 1:
|
||||||
@@ -89,11 +89,12 @@ def _atl_props(text: str) -> dict[str, Any]:
|
|||||||
if key in POSITION_PROPS:
|
if key in POSITION_PROPS:
|
||||||
raw = tokens[i + 1]
|
raw = tokens[i + 1]
|
||||||
try:
|
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):
|
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:
|
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
|
i += 1
|
||||||
except (ValueError, SyntaxError):
|
except (ValueError, SyntaxError):
|
||||||
pass
|
pass
|
||||||
@@ -103,6 +104,25 @@ def _atl_props(text: str) -> dict[str, Any]:
|
|||||||
return props
|
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]:
|
def _split_top(source: str) -> list[str]:
|
||||||
parts, depth, cur = [], 0, []
|
parts, depth, cur = [], 0, []
|
||||||
for ch in source:
|
for ch in source:
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ class ScriptData:
|
|||||||
self.warning_examples[kind] = where
|
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+["'])""")
|
TRANSLATED_SPEAKER = re.compile(r"""^_{1,2}\(\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*\)(?=\s+["'])""")
|
||||||
|
|
||||||
|
|
||||||
@@ -468,7 +469,7 @@ class RpyParser:
|
|||||||
c.keyword("python")
|
c.keyword("python")
|
||||||
self.init_python(line.block_source(), line)
|
self.init_python(line.block_source(), line)
|
||||||
return []
|
return []
|
||||||
for child in line.block:
|
for child in self.fold_live2d(line.block):
|
||||||
for node in self.statement(child, top_level=True):
|
for node in self.statement(child, top_level=True):
|
||||||
if node["op"] == "exec":
|
if node["op"] == "exec":
|
||||||
self.d.init_code.extend(node["code"])
|
self.d.init_code.extend(node["code"])
|
||||||
@@ -476,6 +477,23 @@ class RpyParser:
|
|||||||
self.d.warn("Оператор сцены внутри init", self.where(child))
|
self.d.warn("Оператор сцены внутри init", self.where(child))
|
||||||
return []
|
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]:
|
def st_python(self, line: Line) -> list[Node]:
|
||||||
c = self.cursor(line)
|
c = self.cursor(line)
|
||||||
c.keyword("python")
|
c.keyword("python")
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ Node = dict[str, Any]
|
|||||||
# Calls whose first argument names a screen to open
|
# Calls whose first argument names a screen to open
|
||||||
OPENERS = {"Show", "ShowTransient", "ToggleScreen", "Hide", "show_screen", "call_screen", "hide_screen"}
|
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"}
|
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}
|
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"}
|
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
|
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
|
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
|
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
|
# Scrolling widgets share state through adjustments; scrollbars are cosmetic in the browser
|
||||||
IGNORED_PREFIXES = ("scrollbar_", "vscrollbar_", "bar_", "side_", "thumb_", "slider_", "vslider_", "left_gutter",
|
IGNORED_PREFIXES = ("scrollbar_", "vscrollbar_", "bar_", "side_", "thumb_", "slider_", "vslider_", "left_gutter",
|
||||||
"right_gutter", "top_gutter", "bottom_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"}
|
"child_size", "mouse", "alt", "properties", "substitute", "prefix", "suffix", "keysym"}
|
||||||
STATE_PREFIXES = ("idle", "hover", "insensitive", "selected", "selected_idle", "selected_hover",
|
STATE_PREFIXES = ("idle", "hover", "insensitive", "selected", "selected_idle", "selected_hover",
|
||||||
"selected_insensitive")
|
"selected_insensitive")
|
||||||
@@ -417,6 +419,10 @@ class ScreenParser:
|
|||||||
if cond is not None:
|
if cond is not None:
|
||||||
return {"choose": {"c": cond, "then": self.at_ref(ast.unparse(tree.body), line),
|
return {"choose": {"c": cond, "then": self.at_ref(ast.unparse(tree.body), line),
|
||||||
"else": self.at_ref(ast.unparse(tree.orelse), 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)
|
return self.transform_ref(source, line)
|
||||||
|
|
||||||
def properties(self, words: list[str], line: Line, lenient: bool = False) -> dict[str, Any]:
|
def properties(self, words: list[str], line: Line, lenient: bool = False) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -90,6 +90,16 @@ BUILDS: dict[str, dict] = {
|
|||||||
},
|
},
|
||||||
"defects": {},
|
"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": {
|
"bb19a33af00324fa027a629c052aee885e72f1b7bee7074403a5b9e20c69e2d8": {
|
||||||
"id": "renpy/the-only-doe-0.05",
|
"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)
|
# 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")},
|
"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)``)
|
# parallax picture that drifts against the mouse (screens: ``add TrackCursor("bg.png", 20)``)
|
||||||
"TrackCursor": {"methods": ()},
|
"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
|
# 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)
|
# 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",
|
"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]
|
say = data.modules["t.rpy"][0]["body"][0]
|
||||||
assert say["op"] == "say" and say["what"] == "Hello there"
|
assert say["op"] == "say" and say["what"] == "Hello there"
|
||||||
assert not data.blockers
|
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"]
|
steps = data.atl["t"]["steps"] if "t" in data.atl else data.transforms["t"]
|
||||||
assert not data.blockers
|
assert not data.blockers
|
||||||
assert [(s["op"], s["t"]) for s in steps] == [("move", 1.0), ("pause", 1.5), ("move", 1.0)]
|
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