feat: report ATL problems only for used transforms; animate moving images
- 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:
1 parent
ff82d882c0
commit
4418da47eb
9 files changed
+133
-12
No files matched your search
@@ -60,8 +60,10 @@ def tokens(text: str) -> list[str]:
|
||||
|
||||
|
||||
class AtlParser:
|
||||
def __init__(self, warn: Callable[[str, Line], None], is_static: Callable[[str], dict | None]):
|
||||
def __init__(self, warn: Callable[[str, Line], None], is_static: Callable[[str], dict | None],
|
||||
on_use: Callable[[str], None] = lambda name: None):
|
||||
self.warn = warn
|
||||
self.on_use = on_use
|
||||
# name -> props when `name` is a known static transform (inlined), else None
|
||||
self.static_transform = is_static
|
||||
|
||||
@@ -137,6 +139,7 @@ class AtlParser:
|
||||
if props:
|
||||
steps.append({"op": "set", "props": props})
|
||||
elif len(parts) == 1 and re.fullmatch(r"[A-Za-z_]\w*", first):
|
||||
self.on_use(first)
|
||||
known = self.static_transform(first)
|
||||
steps.append({"op": "set", "props": known} if known is not None else {"op": "use", "name": first})
|
||||
else:
|
||||
|
||||
@@ -103,7 +103,7 @@ CALLS |= {f"renpy.{channel}.{method}" for channel in ("music", "sound")
|
||||
# rollback bookkeeping). They never change the story state.
|
||||
CALLS |= {f"renpy.{channel}.{method}" for channel in ("music", "sound")
|
||||
for method in ("set_volume", "set_pause", "set_pan")}
|
||||
CALLS |= {"renpy.end_replay", "renpy.block_rollback"}
|
||||
CALLS |= {"renpy.end_replay", "renpy.block_rollback", "renpy.transition"}
|
||||
CALLS |= {"map", "filter", "hasattr", "getattr", "setattr", "callable", "super"}
|
||||
METHODS = set("append extend insert pop remove index count reverse sort clear copy add discard get keys values items setdefault update lower upper capitalize title strip lstrip rstrip startswith endswith replace split join format find isdigit isalpha".split())
|
||||
|
||||
|
||||
@@ -292,9 +292,14 @@ def _size(path: Path) -> int:
|
||||
return sum(p.stat().st_size for p in path.rglob("*") if p.is_file())
|
||||
|
||||
|
||||
# Directories inside game/ that are not the story: engine caches, saves, translations, and
|
||||
# add-on tooling players drop in (0x52-URM is an in-game console / save editor).
|
||||
ENGINE_DIRS = ("cache", "saves", "tl", "renpy", "0x52-urm")
|
||||
|
||||
|
||||
def _is_engine_file(game_dir: Path, p: Path) -> bool:
|
||||
parts = [x.lower() for x in p.relative_to(game_dir).parts]
|
||||
return len(parts) > 1 and parts[0] in ("cache", "saves", "tl", "renpy")
|
||||
return len(parts) > 1 and parts[0] in ENGINE_DIRS
|
||||
|
||||
|
||||
def _resolve(path: str, aliases: dict[str, str]) -> str | None:
|
||||
|
||||
@@ -76,6 +76,8 @@ def expr(node: ast.AST) -> Expr:
|
||||
return {"k": "name", "id": name}
|
||||
case ast.Attribute(value=ast.Name(id="store"), attr=attr):
|
||||
return {"k": "name", "id": attr}
|
||||
case ast.Attribute(value=ast.Attribute(value=ast.Name(id="renpy"), attr="store"), attr=attr):
|
||||
return {"k": "name", "id": attr} # renpy.store.x is the same variable as x
|
||||
case ast.Attribute(value=value, attr=attr):
|
||||
return {"k": "attr", "o": expr(value), "a": attr}
|
||||
case ast.Subscript(value=value, slice=ast.Slice(lower=lo, upper=hi, step=st)):
|
||||
@@ -192,7 +194,11 @@ def stmt(node: ast.stmt) -> list[Stmt]:
|
||||
if mod.split(".")[0] in KNOWN_MODULES:
|
||||
return []
|
||||
raise Unsupported(f"импорт модуля {mod}")
|
||||
case ast.FunctionDef(decorator_list=decos) if any(
|
||||
ast.unparse(d).split("(")[0] in INACTIVE_VARIANTS for d in decos):
|
||||
return [] # screen-size/touch variants never apply to the desktop browser player
|
||||
case ast.FunctionDef(name=name, args=args, body=body, decorator_list=decos):
|
||||
decos = [d for d in decos if ast.unparse(d) not in TRANSPARENT_DECORATORS]
|
||||
return [{"op": "def", "name": name, "params": params(args), "body": function_body(body),
|
||||
"decos": [decorator(d) for d in decos]}]
|
||||
case ast.ClassDef(name=name, bases=bases, keywords=[], body=body, decorator_list=[]):
|
||||
@@ -212,6 +218,8 @@ def stmt(node: ast.stmt) -> list[Stmt]:
|
||||
# Methods the player can run implicitly. Any other dunder method would silently
|
||||
# never be called, so the converter refuses it instead.
|
||||
SUPPORTED_DUNDERS = {"__init__", "__str__", "__repr__"}
|
||||
INACTIVE_VARIANTS = {"gui.variant", "renpy.variant"}
|
||||
TRANSPARENT_DECORATORS = {"renpy.pure"} # marks a function as side-effect free; no runtime meaning
|
||||
DECORATORS = {"staticmethod", "classmethod", "property"}
|
||||
|
||||
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -102,9 +102,14 @@ def test_renpy_atl_animation_becomes_a_timeline_not_a_static_pose():
|
||||
def test_renpy_atl_constructs_without_a_runtime_are_still_rejected():
|
||||
for body in ("function slide", "time 1.0:\n xalign 1.0", "matrixcolor TintMatrix('#f00')",
|
||||
"contains:\n 'a.png'"):
|
||||
data = script(f'transform odd:\n {body}\nlabel start:\n "Hi"')
|
||||
unused = script(f'transform odd:\n {body}\nlabel start:\n "Hi"')
|
||||
require_supported_scripts(unused) # nothing shows it, so it cannot break the game
|
||||
used = script(f'transform odd:\n {body}\nlabel start:\n show eileen at odd\n "Hi"')
|
||||
with pytest.raises(CompatibilityError, match="ATL"):
|
||||
require_supported_scripts(data)
|
||||
require_supported_scripts(used)
|
||||
early = script(f'label start:\n show eileen at odd\n "Hi"\ntransform odd:\n {body}\n')
|
||||
with pytest.raises(CompatibilityError, match="ATL"): # used before it is defined
|
||||
require_supported_scripts(early)
|
||||
|
||||
|
||||
def test_renpy_atl_groups_events_and_named_transforms():
|
||||
|
||||
@@ -164,3 +164,34 @@ def test_atl_frames_layeredimage_and_channels():
|
||||
"img": {"ref": "eileen_face_happy"}}
|
||||
assert layers[2]["img"] == {"file": "sad.png"}
|
||||
assert d.channels["amb"] == {"mixer": "sfx", "loop": True}
|
||||
|
||||
|
||||
def test_moving_image_keeps_its_animation():
|
||||
d = parse('image spin:\n "mark.png"\n xalign 0.5\n linear 0 rotate 0\n linear 4 rotate -360\n repeat\n')
|
||||
image = d.images["spin"]
|
||||
assert image["file"] == "mark.png"
|
||||
spec = image["at"][0]
|
||||
assert spec["props"] == {"xalign": 0.5}
|
||||
assert spec["atl"]["repeat"] is True and spec["atl"]["steps"][-1]["props"] == {"rotate": -360.0}
|
||||
assert not d.blockers
|
||||
|
||||
|
||||
def test_unused_transforms_with_unsupported_atl_do_not_block():
|
||||
d = parse("transform spinner(delay, cycle):\n function renpy.display.layout.example\nlabel start:\n 'Hi'\n")
|
||||
assert not d.blockers
|
||||
|
||||
|
||||
def test_gui_variants_and_renpy_store_are_understood():
|
||||
d = parse(
|
||||
"init python:\n"
|
||||
" @gui.variant\n"
|
||||
" def small():\n"
|
||||
" gui.text_size = 30\n"
|
||||
" @renpy.pure\n"
|
||||
" def twice(x):\n"
|
||||
" return x * 2\n"
|
||||
" renpy.store.seen.append(1)\n")
|
||||
assert [s["op"] for s in d.init_code] == ["def", "eval"]
|
||||
assert d.init_code[0]["decos"] == [] and d.init_code[0]["name"] == "twice"
|
||||
assert d.init_code[1]["e"]["f"]["o"] == {"k": "name", "id": "seen"}
|
||||
assert not d.blockers
|
||||
@@ -269,6 +269,8 @@ export class Engine {
|
||||
},
|
||||
stop: (...a: any[]) => this.audio.stop(kwOf(a).channel ?? ch, kwOf(a).fadeout ?? 0),
|
||||
set_volume: () => {},
|
||||
set_pause: () => {},
|
||||
set_pan: () => {},
|
||||
get_playing: () => this.audio.current(ch)?.file ?? null,
|
||||
is_playing: () => this.audio.current(ch) !== null,
|
||||
});
|
||||
@@ -301,6 +303,10 @@ export class Engine {
|
||||
throw new JumpSignal("jump", "__quit__");
|
||||
},
|
||||
version: () => "pgvn",
|
||||
// presentation-only: nothing to roll back or replay, and the next transition is chosen by `with`
|
||||
end_replay: () => null,
|
||||
block_rollback: () => null,
|
||||
transition: () => null,
|
||||
};
|
||||
const renpy = new Proxy(renpyImpl, {
|
||||
get: (t, k: string) => {
|
||||
|
||||
@@ -91,7 +91,7 @@ export class Renderer {
|
||||
this.master.insertBefore(el, behind ?? later ?? null);
|
||||
}
|
||||
this.shown.set(tag, entry);
|
||||
this.animate(entry, base, atSpecs);
|
||||
this.animate(entry, base, [...(img.def.at ?? []), ...atSpecs]);
|
||||
}
|
||||
|
||||
/** Start the ATL timelines of an image; each works on its own copy of the properties. */
|
||||
|
||||
Reference in new issue
Block a user