feat: report ATL problems only for used transforms; animate moving images
CI / frontend (push) Successful in 23s
CI / backend (push) Successful in 1m55s

- Unsupported ATL constructs in a transform now block the import only when the
  transform is used (at clause, another transform, even before its definition).
  Stock screens.rpy transforms for unused screens no longer fail games
- image blocks with one picture plus linear/ease/parallel/repeat keep their
  animation (image-level ATL); the renderer also starts it
- Python: skip @gui.variant/@renpy.variant functions (inactive variants), treat
  @renpy.pure as transparent, read renpy.store.x as x
- Accept renpy.transition, end_replay, block_rollback and music set_pause/set_pan
  as presentation-only no-ops, and implement them in the player (set_pause and
  set_pan were allowed by the converter but missing at runtime)
- Skip the 0x52-URM add-on directory (in-game console / save editor) when
  parsing scripts and media
- Tests for lazy ATL warnings, moving images and variants

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 12:20:39 +03:00
1 parent ff82d882c0
commit 4418da47eb
9 files changed
+133 -12

No files matched your search

+69 -6
View File
@@ -6,7 +6,7 @@ from dataclasses import dataclass, field
from typing import Any
from app.converter import pyexpr
from app.converter.atl import AtlParser, initial_props, static_props
from app.converter.atl import WARPER, AtlParser, initial_props, static_props
from app.converter.pyexpr import Expr, Stmt, Unsupported, const
from app.converter.rpy_helpers import (
_CALL_LINE,
@@ -69,6 +69,11 @@ class ScriptData:
images: dict[str, dict[str, Any]] = field(default_factory=dict)
transforms: dict[str, dict[str, Any]] = field(default_factory=dict)
atl: dict[str, dict[str, Any]] = field(default_factory=dict) # animated transforms by name
# A transform's unsupported constructs only matter once something uses it (Ren'Py's stock
# screens.rpy defines several that no game ever shows).
atl_pending: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
atl_uses: dict[str, set[str]] = field(default_factory=dict)
atl_referenced: set[str] = field(default_factory=set)
transitions: dict[str, dict[str, Any] | None] = field(default_factory=dict)
audio: dict[str, str] = field(default_factory=dict)
defaults: list[list[Any]] = field(default_factory=list)
@@ -589,7 +594,34 @@ class RpyParser:
self.d.warn(f"Изображение {fname}() не поддерживается", self.where(line))
return None
MOTION_WORDS = {"linear", "ease", "easein", "easeout", "easeinout", "parallel", "choice", "repeat",
"block", "on"}
def displayable_line(self, child: Line) -> bool:
if self.cursor(child).string() is not None:
return True
m = _CALL_LINE.match(child.text)
return bool(m and m.group(1) in _DISPLAYABLE_CALLS)
def atl_image(self, line: Line) -> dict[str, Any] | None:
shown = [child for child in line.block if self.displayable_line(child)]
rest = [child for child in line.block if not self.displayable_line(child)]
if len(shown) == 1 and any(
(word := Cursor(child.text).peek_word()) in self.MOTION_WORDS or WARPER.match(word or "")
for child in rest):
# one picture that moves, e.g. a spinning "click to continue" mark
first = self.cursor(shown[0]).string()
if first is not None:
img = self.displayable_expr(const(first), shown[0])
else:
expr = self.expr(shown[0].text.split(" with ")[0], shown[0])
img = self.displayable_expr(expr, shown[0]) if expr else None
if img:
props, animation = self.atl_spec(rest)
spec: dict[str, Any] = {"props": props}
if animation:
spec["atl"] = animation
return {**img, "at": [*img.get("at", []), spec]}
frames: list[dict[str, Any]] = []
props: dict[str, Any] = {}
repeat = self.atl_frames(line.block, frames, props)
@@ -779,17 +811,43 @@ class RpyParser:
c.keyword("transform")
name = c.word()
if name:
props, animation = self.atl_spec(line.block)
issues: list[tuple[str, str]] = []
uses: set[str] = set()
props, animation = self.atl_spec(line.block, issues.append, uses)
self.d.transforms[name] = props
if animation:
self.d.atl[name] = animation
self.d.atl_pending[name] = issues
self.d.atl_uses[name] = uses
if name in self.d.atl_referenced:
self.use_transform(name)
return []
def atl_spec(self, lines: list[Line]) -> tuple[dict[str, Any], dict[str, Any] | None]:
def use_transform(self, name: str) -> None:
"""A transform is used by the game: its unsupported constructs now block the import."""
self.d.atl_referenced.add(name)
pending = self.d.atl_pending.pop(name, [])
for message, where in pending:
self.d.warn(message, where)
for other in self.d.atl_uses.get(name, ()):
self.use_transform(other)
def atl_spec(self, lines: list[Line], collect=None, uses=None) -> tuple[dict[str, Any], dict[str, Any] | None]:
"""Parse an ATL block: (properties to start with, timeline or None when it is static)."""
parser = AtlParser(lambda message, line: self.d.warn(message, self.where(line)),
lambda name: self.d.transforms.get(name) if name not in self.d.atl
and name in self.d.transforms else None)
def warn(message: str, line: Line) -> None:
if collect is None:
self.d.warn(message, self.where(line))
else:
collect((message, self.where(line)))
def on_use(name: str) -> None:
if uses is None:
self.use_transform(name)
else:
uses.add(name)
parser = AtlParser(warn, lambda name: self.d.transforms.get(name) if name not in self.d.atl
and name in self.d.transforms else None, on_use)
block = parser.block(lines)
static = static_props(block)
if static is not None:
@@ -854,6 +912,8 @@ class RpyParser:
source = source.strip()
if source in POSITIONS:
return {"pos": source}
if source.isidentifier():
self.use_transform(source)
if source in self.d.transforms:
spec = {"props": self.d.transforms[source], "name": source}
if source in self.d.atl:
@@ -873,10 +933,13 @@ class RpyParser:
return {"effect": "shake", "d": args[1] if len(args) > 1 else 0.5,
"dist": _const(e["kw"].get("dist")) or 20}
if fname in self.d.transforms:
self.use_transform(fname)
spec = {"props": self.d.transforms[fname], "name": fname}
if fname in self.d.atl:
spec["atl"] = fname
return spec
if e and e["k"] == "name":
self.use_transform(e["id"])
if e and e["k"] == "name" and e["id"] not in self.d.transforms:
# transform defined later in another file; the player resolves it by name
return {"name": e["id"]}