Files
pgames-manager/backend/app/converter/compatibility.py
T
mrleo1nidandClaude Sonnet 5.5 3f51f06fec
CI / frontend (push) Failing after 1m13s
CI / backend (push) Failing after 1m33s
feat: render API shim - a game's renpy.Displayable subclasses are drawn and receive events
Games write their own displayables with render(), renpy.Render, blit, fill, canvas() and renpy.redraw.
render.ts keeps a tree of placed pieces (child renders, ordinary displayables, fills and drawing
commands) and turns it into DOM elements that stay put between redraws; pointer events reach event()
as pygame-style objects, and a returned value ends the interaction. Shaders and GPU properties are
accepted and ignored. The converter whitelists the calls and methods and accepts `import pygame`.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 15:20:35 +03:00

583 lines
30 KiB
Python

"""Engine detection and fail-closed import diagnostics. Never runs game executables."""
import os
from dataclasses import asdict, dataclass
from pathlib import Path, PurePosixPath
@dataclass(frozen=True)
class Issue:
code: str
message: str
location: str = ""
count: int = 1
class CompatibilityError(RuntimeError):
def __init__(self, engine: str, issues: list[Issue]):
self.engine, self.issues = engine, issues
super().__init__("Импорт остановлен: " + "; ".join(
i.message + (f" ({i.location})" if i.location else "") for i in issues[:5]))
def report(self) -> dict:
return {"error": str(self), "engine": self.engine, "compatible": False,
"issues": [asdict(i) for i in self.issues]}
@dataclass(frozen=True)
class Detection:
engine: str
root: Path
# Development copies and installed patches must not be mistaken for additional games.
IGNORED = {".git", ".godot", ".local-translator", "migration-staging", "migration-tools",
"node_modules", "__macosx"}
def detect(root: Path) -> Detection:
candidates: dict[tuple[str, Path], Detection] = {}
for directory, dirs, files in os.walk(root, followlinks=False):
dirs[:] = sorted(d for d in dirs if d.lower() not in IGNORED and
not Path(directory, d).is_symlink())
path = Path(directory)
names = {f.lower() for f in files}
lower = {d.lower() for d in dirs}
engine = None
if path.name.lower().endswith("_data") and ("managed" in lower or "il2cpp_data" in lower):
managed = path / next((d for d in dirs if d.lower() == "managed"), "Managed")
dlls = {p.name.lower() for p in managed.glob("*.dll")}
engine = "naninovel" if any("naninovel" in n for n in dlls) else "unity"
elif "data.win" in names:
engine = "gamemaker"
elif "project.godot" in names or any(n.endswith(".pck") for n in names):
engine = "godot"
elif "game.rgss3a" in names or "game.rgss2a" in names or "game.rgssad" in names:
engine = "rpgmaker"
elif "rpg_core.js" in names or "rmmz_core.js" in names:
engine = "rpgmaker"
elif path.name.lower() == "game" and any(
PurePosixPath(n).suffix in {".rpy", ".rpyc", ".rpa"} for n in names):
engine = "renpy"
if engine:
game_root = path.parent if engine in {"naninovel", "unity", "renpy"} else path
candidates[(engine, game_root)] = Detection(engine, game_root)
# Engine internals are not independent games.
dirs.clear()
if not candidates:
# Unpacked Ren'Py script-only archives do not always have a game/ wrapper.
from app.converter.archive import ArchiveError, find_game_dir
try:
game = find_game_dir(root)
return Detection("renpy", game)
except ArchiveError:
raise CompatibilityError("unknown", [Issue("unknown_engine",
"Движок не распознан; нужен полный дистрибутив игры, а не отдельный EXE или патч")]) from None
if len(candidates) != 1:
raise CompatibilityError("ambiguous", [Issue("multiple_games",
"В одном импорте обнаружено несколько игр; загрузите каждую отдельно",
", ".join(f"{c.engine}: {c.root.relative_to(root)}" for c in candidates.values()))])
result = next(iter(candidates.values()))
from app.games.registry import all_profiles
if result.engine not in {profile.engine for profile in all_profiles()}:
raise CompatibilityError(result.engine, [Issue("unsupported_engine",
f"Движок {result.engine} распознан, но его исполнение в PGames не поддерживается")])
return result
def require_supported_scripts(data, engine: str = "renpy") -> None:
issues = [Issue("unsupported_script", message, data.blocker_examples.get(message, ""), count)
for message, count in data.blockers.items()]
if issues:
raise CompatibilityError(engine, issues)
CALLS = set("len str int float bool abs round min max sum any all range list tuple set frozenset dict sorted reversed enumerate zip isinstance _ __".split())
CALLS |= {prefix + method for prefix in ("random.", "renpy.random.")
for method in ("randint", "randrange", "random", "choice", "choices", "sample", "shuffle", "uniform",
"Random")}
CALLS |= {"math." + method for method in ("floor", "ceil", "sqrt", "pow")}
CALLS |= {"renpy." + method for method in ("play", "notify", "jump", "call", "input", "has_label",
"seen_label", "showing", "get_attributes", "full_restart", "quit", "version")}
CALLS |= {f"renpy.{channel}.{method}" for channel in ("music", "sound")
for method in ("play", "queue", "stop", "get_playing", "is_playing")}
# Presentation-only calls the player accepts and ignores (volume/pan/pause tweaks, replay and
# 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", "renpy.transition", "renpy.choice_for_skipping",
"renpy.save", "renpy.load"}
# Image prediction and rollback checkpoints only warm caches or mark undo points
CALLS |= {"renpy.start_predict", "renpy.stop_predict", "renpy.start_predict_screen",
"renpy.stop_predict_screen", "renpy.checkpoint", "gc.get_objects", "gc.collect",
"_p", "renpy.translate_string", "renpy.substitute", "renpy.save_persistent", "renpy.mark_label_seen",
"renpy.get_game_runtime", "renpy.register_textshader", "ui.adjustment", "divmod", "pow",
"achievement.register", "achievement.grant", "achievement.has", "achievement.progress",
"achievement.clear", "achievement.sync",
"renpy.music.get_pos", "renpy.music.get_duration", "renpy.music.get_pause",
*(name for name in ("AttributeError", "SyntaxError", "KeyError", "ValueError", "TypeError",
"NotImplementedError", "IndexError", "ZeroDivisionError", "RuntimeError",
"StopIteration", "AssertionError", "NameError", "ImportError", "OSError")),
"renpy.ui.imagebutton", "renpy.return_statement"}
from app.games.renpy_native import NATIVE_CLASSES, NATIVE_METHODS # noqa: E402
CALLS |= set(NATIVE_CLASSES)
# The render API for games' own displayables (frontend/src/player/render.ts)
CALLS |= {"renpy.Render", "renpy.render", "renpy.redraw", "renpy.displayable", "renpy.IgnoreEvent",
"renpy.display.layout.LiveComposite", "renpy.display.layout.Fixed", "pygame.key.get_mods",
"pygame.mouse.get_pressed", "pygame.mouse.get_pos"}
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"}
CALLS |= {"dir", "datetime.date", "datetime.datetime", "datetime.timedelta", "date", "timedelta",
"renpy.style.rebuild", "renpy.image_size"}
CALLS |= {"map", "filter", "iter", "hash", "hasattr", "getattr", "setattr", "callable", "super"}
# Screen actions and values the player implements (see frontend/src/player/screens.ts)
SCREEN_CALLS = set("""Jump Call Return SetVariable ToggleVariable SetDict SetField ToggleField
SetScreenVariable ToggleScreenVariable Show Hide ShowTransient Function NullAction Play Stop Notify
Quit MainMenu If GetTooltip StaticValue VariableValue AnimatedValue FieldValue SelectedIf
SensitiveIf Queue ToggleScreen Language ShowMenu OpenURL Crop LiveCrop Solid Frame Null Transform
DynamicDisplayable Color Dissolve Fade ImageDissolve MoveTransition CropMove PushMove Pixellate
ComposeTransition With Text SetLocalVariable QuickSave QuickLoad AlphaMask Fixed At
TintMatrix BrightnessMatrix SaturationMatrix ContrastMatrix InvertMatrix OpacityMatrix HueMatrix
ColorizeMatrix IdentityMatrix print Exception BaseException Character""".split())
SCREEN_CALLS |= set(NATIVE_CLASSES)
SCREEN_CALLS |= {"SetMixer", "ImageReference"}
# Save/load screens of the game's own (slots are kept in the browser; a slot has no screenshot, see screens.ts)
SCREEN_CALLS |= set("""FileAction FileSave FileLoad FileDelete FileTakeScreenshot FilePage FilePageNext FilePagePrevious
FileLoadable FileUsedSlot FileNewest FileSaveName FileTime FileJson FileScreenshot FileCurrentScreenshot
FileCurrentPage FilePageName FileSlotName""".split())
SCREEN_CALLS |= {"renpy.translation.translate_string"}
# Screen language helpers for audio, focus and pictures that play (see screens.ts)
SCREEN_CALLS |= {"Movie", "MixerValue", "AudioPositionValue", "PauseAudio", "ToggleSetMembership",
"GetFocusRect", "ToggleFocus", "XScrollValue", "YScrollValue", "Preference", "Start"}
SCREEN_CALLS |= {"im." + name for name in (
"MatrixColor Scale FactorScale Flip Crop Grayscale Sepia Alpha Blur Composite Tile Rotozoom "
"matrix.brightness matrix.contrast matrix.saturation matrix.tint matrix.identity matrix.invert "
"matrix.opacity matrix.hue matrix.colorize").split()}
SCREEN_CALLS |= {"Image", "re.sub", "re.match", "re.search", "re.findall", "re.split", "re.escape",
"copy.copy", "copy.deepcopy", "time.time", "time.strftime", "time.sleep",
"datetime.now", "datetime.datetime.now", "datetime.today", "datetime.date.today",
"collections.defaultdict", "collections.OrderedDict", "renpy.partial", "renpy.loadable",
"renpy.is_init_phase", "renpy.call_stack_depth", "renpy.const", "renpy.screenshot",
"renpy.Keymap", "renpy.register_shader", "renpy.register_statement", "renpy.redraw",
"renpy.stop_predict", "renpy.map_event", "renpy.count_displayables_in_layer",
"renpy.displayable", "renpy.game.context", "renpy.unlink_save", "json.dumps", "json.loads",
"_window_hide", "_window_show", "functools.partial"}
CALLS |= SCREEN_CALLS
CALLS |= SCREEN_CALLS | {"globals", "renpy.variant", "renpy.change_language", "renpy.seen_image",
"renpy.show_screen", "renpy.hide_screen", "renpy.restart_interaction",
"renpy.get_screen", "renpy.call_screen"}
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 zfill ljust rjust center partition rpartition rsplit splitlines isupper islower isspace isalnum swapcase rfind has_key iteritems iterkeys itervalues strftime isoformat weekday group groups groupdict start end".split())
METHODS |= NATIVE_METHODS
# Methods of generators made by `renpy.random.Random()` / `random.Random()`
METHODS |= {"blit", "subpixel_blit", "fill", "canvas", "get_size", "add_shader", "add_uniform", "add_property",
"add_focus", "circle", "rect", "line", "lines", "polygon"}
METHODS |= {"fullmatch", "hexdigest", "randint", "randrange", "random", "choice", "choices", "sample", "shuffle", "uniform"}
def _bound_names(root) -> tuple[set[str], set[str]]:
"""Names the game binds itself and the method names of its classes.
Calling any of them is the game's own code, which the player interprets.
"""
names: set[str] = set()
methods: set[str] = set()
def target(value):
if isinstance(value, dict):
if value.get("k") == "name":
names.add(value["id"])
elif value.get("k") == "tuple":
for item in value["v"]:
target(item)
def visit(value, in_class=False):
if isinstance(value, list):
for item in value:
visit(item, in_class)
elif isinstance(value, dict):
op, kind = value.get("op"), value.get("k")
if op == "def":
(methods if in_class else names).add(value["name"])
names.update(p["n"] for p in value["params"])
elif op == "class":
names.add(value["name"])
elif op in ("set", "aug", "for"):
target(value.get("t"))
elif kind == "lambda":
names.update(p["n"] for p in value["params"])
elif value.get("c") == "for":
target(value.get("t"))
elif "params" in value and "defaults" in value and "body" in value: # a screen
names.update(p["n"] for p in value["params"])
names.update(name for name, _ in value["defaults"])
elif kind == "comp":
target(value.get("t"))
for gen in value.get("gens", []):
target(gen.get("t"))
elif op == "try":
names.update(h["name"] for h in value.get("handlers", []) if h.get("name"))
for key, child in value.items():
visit(child, op == "class" and key == "body")
visit(root)
return names, methods
SCREEN_OPENERS = {"renpy.show_screen", "renpy.call_screen", "renpy.hide_screen", "Show", "ShowTransient",
"Hide"}
# Labels the engine itself enters
ENGINE_ROOTS = {"start", "splashscreen", "before_main_menu", "main_menu", "after_load", "_after_load", "quit",
"after_warp", "hide_windows", "_hide_windows", "main_menu_screen", "_main_menu_screen", "game_menu"}
def live_modules(data) -> dict[str, list]:
"""The scripts' labels that can run: entered by the engine, or named by reachable code.
Code nobody can reach (a recovery path for engine save files, a debug menu whose entry was removed)
is not audited. Names are matched as jump/call targets and as string constants, and a game that jumps
to computed labels is treated as able to reach every label."""
from app.converter.pipeline import anchors
owner: dict[str, tuple[str, dict]] = {}
for path, module in data.modules.items():
for label in module:
owner[label["name"]] = (path, label)
for name in anchors([label]):
owner.setdefault(name, (path, label))
def computed(value) -> bool:
stack = [value]
while stack:
item = stack.pop()
if isinstance(item, list):
stack.extend(item)
elif isinstance(item, dict):
if item.get("op") in ("jump", "call") and "expr" in item:
return True
fn = item.get("f", {}) if item.get("k") == "call" else {}
if fn.get("k") == "attr" and fn.get("a") in ("jump", "call", "call_in_new_context") and fn["o"].get("k") == "name" and fn["o"]["id"] == "renpy" and not (item["a"] and item["a"][0].get("k") == "const"):
return True
stack.extend(item.values())
return False
if computed([data.modules, data.init_code, data.defaults, list(data.screens.values())]):
return data.modules
names = set(owner)
seen: set[int] = set()
pending = [owner[n][1] for n in ENGINE_ROOTS if n in owner]
base = referenced_strings([data.init_code, data.defaults, list(data.screens.values()), data.images]) & names
pending += [owner[n][1] for n in base]
while pending:
label = pending.pop()
if id(label) in seen:
continue
seen.add(id(label))
for name in referenced_strings([label["body"]]) & names:
pending.append(owner[name][1])
stack = [label["body"]]
while stack:
item = stack.pop()
if isinstance(item, list):
stack.extend(item)
elif isinstance(item, dict):
if item.get("op") in ("jump", "call") and item.get("target") in owner:
pending.append(owner[item["target"]][1])
stack.extend(item.values())
return {path: [label for label in module if id(label) in seen] for path, module in data.modules.items()}
def mark_screen_calls(data) -> None:
"""`renpy.show_screen("x")` in game code uses screen x, just like `show screen x`.
A screen that picks another one at run time (``use expression``) may use any screen whose
name the game's Python code lists in a tuple or list (a table of screens)."""
dynamic = [False]
in_init = [False]
live = live_modules(data)
def use(name):
if name in data.screens and name not in data.screen_used:
data.use_screen(name)
changed.append(name)
def visit(value):
if isinstance(value, list):
for item in value:
visit(item)
elif isinstance(value, dict):
if value.get("c") == "use" and "nameExpr" in value and not dynamic[0]:
dynamic[0] = True
changed.append("dynamic")
if dynamic[0] and in_init[0] and value.get("k") in ("tuple", "list") and isinstance(value.get("v"), list):
for item in value["v"]: # `("chat_entry", kwargs)`: a table of screen names and their data
if isinstance(item, dict) and item.get("k") == "const" and isinstance(item.get("v"), str):
use(item["v"])
if value.get("k") == "call" and value.get("a"):
name = value["f"]["id"] if value["f"].get("k") == "name" else (
"renpy." + value["f"]["a"] if value["f"].get("k") == "attr"
and value["f"]["o"].get("k") == "name" and value["f"]["o"]["id"] == "renpy" else "")
first = value["a"][0]
if name in SCREEN_OPENERS and first.get("k") == "const" and isinstance(first["v"], str):
use(first["v"])
for child in value.values():
visit(child)
changed = [True]
while changed:
changed.clear()
in_init[0] = True
visit(data.init_code)
in_init[0] = False
visit([live, data.defaults, [data.screens[n] for n in data.screen_used if n in data.screens]])
IMPLICIT_METHODS = {"__init__", "__str__", "__repr__", "__hash__", "__eq__", "__iter__", "__getitem__", "__contains__"}
# Labels Ren'Py itself defines (renpy/common); jumping to them goes to the player's own menu
ENGINE_LABELS = {"main_menu_screen", "_main_menu_screen", "main_menu"}
# Calls that make the player wait; Python containing them runs on the player's generator interpreter
BLOCKING_BUILTINS = {"renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement",
"renpy.call_screen", "renpy.movie_cutscene", "ui.interact"}
CALLS |= {"renpy.free_memory", "achievement.clear_all", "renpy.image", "ImageReference", "SetMixer"}
CALLS |= BLOCKING_BUILTINS | {"renpy.show", "renpy.hide", "renpy.scene", "Borders", "renpy.set_tag_attributes",
"eval", "exec", "Replay", "EndReplay", "renpy.end_replay",
"renpy.get_showing_tags", "renpy.run", "renpy.pop_call",
"renpy.curry", "_preferences.get_mute", "_preferences.get_volume",
"preferences.get_mute", "preferences.get_volume"}
CALLS |= {"math." + name for name in (
"sin cos tan asin acos atan atan2 exp fabs trunc hypot log log10 fmod modf degrees radians isnan isinf "
"copysign").split()}
def blocking_names(data) -> list[str]:
"""Names of game functions and methods that may wait for the player, directly or through each other."""
definitions: dict[str, list] = {}
def collect(statements: list) -> None:
for statement in statements:
if statement.get("op") == "def":
definitions.setdefault(statement["name"], []).append(statement)
elif statement.get("op") == "class":
collect([m for m in statement["body"] if m.get("op") == "def"])
collect(data.init_code)
waiting = BLOCKING_BUILTINS | set(data.characters) | {"narrator"}
found: set[str] = set()
def waits(node, known: set[str]) -> bool:
stack = [node]
while stack:
value = stack.pop()
if isinstance(value, list):
stack.extend(value)
elif isinstance(value, dict):
if value.get("k") == "call":
function = value["f"]
last = function.get("id") if function.get("k") == "name" else function.get("a")
dotted = (function["o"]["id"] + "." + function["a"]) if (function.get("k") == "attr" and
function["o"].get("k") == "name") else last
if dotted in waiting or last in known or last in waiting:
return True
stack.extend(value.values())
return False
changed = True
while changed:
changed = False
for name, nodes in definitions.items():
if name not in found and any(waits(n["body"], found) for n in nodes):
found.add(name)
changed = True
return sorted(found)
def reachable_init_code(data, used_screens, modules=None) -> list:
"""The part of `init python` that can run: startup statements plus every function, class and
method that something reachable refers to (by name or as an attribute).
Definitions nobody refers to - lexers of custom statements, helpers of unused tools - are left
out of the audit; if the game ever calls one, the player stops with an explicit error.
"""
roots: list = []
definitions: dict[str, list] = {}
def collect(statements: list) -> None:
for statement in statements:
op = statement.get("op")
if op == "def":
definitions.setdefault(statement["name"], []).append(statement)
elif op == "class":
definitions.setdefault(statement["name"], []).append(statement)
collect([m for m in statement["body"] if m.get("op") == "def"])
else:
roots.append(statement)
collect(data.init_code)
result: list = list(roots)
taken: set[int] = set()
queue: list = [roots, list((modules or data.modules).values()), data.defaults, data.images, list(used_screens.values())]
while queue:
fragment = queue.pop()
stack = [fragment]
while stack:
value = stack.pop()
if isinstance(value, list):
stack.extend(value)
elif isinstance(value, dict):
name = value.get("id") if value.get("k") == "name" else value.get("a") if value.get("k") == "attr" else None
for node in definitions.get(name, ()) if name else ():
if id(node) in taken:
continue
taken.add(id(node))
if node["op"] == "class":
# class-level assignments and the methods Python calls implicitly
own = [m for m in node["body"] if m.get("op") != "def" or m["name"] in IMPLICIT_METHODS]
result.extend(own)
queue.append(own)
else:
result.append(node)
queue.append(node)
stack.extend(value.values())
return result
def drop_transform_pauses(data) -> None:
"""An ATL line naming a transform may instead be a number the game computes (a pause). Once every
transform is known, a name that is one needs no such fallback (and nothing to audit)."""
known = set(data.atl) | set(data.transforms)
stack: list = [data.modules, data.images, data.atl, data.screens, data.transforms]
while stack:
value = stack.pop()
if isinstance(value, list):
stack.extend(value)
elif isinstance(value, dict):
if value.get("op") == "use" and "pause" in value and value.get("name") in known:
del value["pause"]
stack.extend(value.values())
def referenced_names(fragments: list) -> set[str]:
"""Every plain name the given IR mentions."""
found: set[str] = set()
stack: list = list(fragments)
while stack:
value = stack.pop()
if isinstance(value, list):
stack.extend(value)
elif isinstance(value, dict):
if value.get("k") == "name":
found.add(value["id"])
stack.extend(value.values())
return found
def referenced_strings(fragments: list) -> set[str]:
"""Every string constant the given IR holds, and the words of a name stored as a list (`show a b`)."""
found: set[str] = set()
stack: list = list(fragments)
while stack:
value = stack.pop()
if isinstance(value, str):
found.add(value)
elif isinstance(value, list):
if value and all(isinstance(item, str) for item in value):
found.add(" ".join(value))
stack.extend(value)
elif isinstance(value, dict):
stack.extend(value.values())
return found
def audit_ir(data) -> None:
"""Reject calls which the browser would otherwise silently replace by None."""
drop_transform_pauses(data)
mark_screen_calls(data)
used_screens = {n: data.screens[n] for n in data.screen_used if n in data.screens}
user_names, user_methods = _bound_names([data.modules, data.init_code, data.defaults, data.images,
list(used_screens.values())])
user_names |= {name for name, _ in data.defaults}
user_names |= set(data.characters) | {"narrator"}
def dotted(value):
if value.get("k") == "name":
return value["id"]
if value.get("k") == "attr":
return dotted(value["o"]) + "." + value["a"]
return ""
def visit(value, location):
if isinstance(value, list):
for item in value:
visit(item, location)
elif isinstance(value, dict):
if value.get("k") == "call":
function = value.get("f", {})
name = dotted(function)
is_method = (function.get("k") == "attr" and not name.startswith(("renpy.", "math.", "random."))
and (function.get("a") in METHODS or function.get("a") in user_methods
or function.get("a") in user_names))
# `MainMenu(confirm=False)()`: calling the action a screen action constructor returned
if function.get("k") == "call" and dotted(function.get("f", {})) in SCREEN_CALLS:
is_method = True
# `globals()[who + "_here"](...)`: a function picked by name; some function must carry that ending
if function.get("k") == "sub" and function["o"].get("k") == "call" \
and dotted(function["o"]["f"]) == "globals":
index = function["i"]
suffix = index["r"]["v"] if index.get("k") == "bin" and index.get("op") == "+" \
and index["r"].get("k") == "const" else None
is_method = isinstance(suffix, str) and any(n.endswith(suffix) for n in user_names)
if name not in CALLS and name not in user_names and not is_method:
data.warn(f"Вызов не поддерживается плеером: {name or 'динамическая функция'}", location)
if value.get("op") in ("jump", "call") and isinstance(value.get("target"), str):
if value["target"] not in labels:
data.warn(f"Не найдена цель перехода: {value['target']}", location)
for child in value.values():
visit(child, location)
from app.converter.pipeline import anchors
labels = {label["name"] for module in data.modules.values() for label in module} | ENGINE_LABELS
labels |= {name for module in data.modules.values() for name in anchors(module)}
live = live_modules(data)
for path, module in live.items():
visit(module, path)
visit(data.defaults, "default")
for name, definition in used_screens.items():
visit(definition, f"screen {name}")
for name in sorted(data.screen_used - set(data.screens)):
data.warn("Не найден screen", name)
reachable = reachable_init_code(data, used_screens, live)
visit(reachable, "init")
visit(data.images, "image")
if data.deferred_images:
# shown by the game itself, or by another picture that is shown (pictures only used by pictures
# nobody shows do not count)
strings = referenced_strings([live, data.defaults, list(used_screens.values()), reachable])
pending = [name for name in data.images if name in strings]
done: set[str] = set()
while pending:
name = pending.pop()
if name in done:
continue
done.add(name)
inner = referenced_strings([data.images[name]])
strings |= inner
pending.extend(n for n in data.images if n in inner and n not in done)
for name, problems in data.deferred_images.items():
if name in strings:
for message, where in problems:
data.warn(message, where)
if data.deferred:
referenced = referenced_names([live, data.defaults, data.images,
list(used_screens.values()), reachable])
for name, problems in data.deferred.items():
if name in referenced:
for message, where in problems:
data.warn(message, where)