Senpy или зачем визуальной новелле семантический линтер, если в Ren’Py уже есть встроенная проверка?. AST.. AST. gamedev.. AST. gamedev. lsp.. AST. gamedev. lsp. Open source.. AST. gamedev. lsp. Open source. python.. AST. gamedev. lsp. Open source. python. renpy.. AST. gamedev. lsp. Open source. python. renpy. ruff.. AST. gamedev. lsp. Open source. python. renpy. ruff. visual novel.. AST. gamedev. lsp. Open source. python. renpy. ruff. visual novel. Visual Studio.. AST. gamedev. lsp. Open source. python. renpy. ruff. visual novel. Visual Studio. визуальные новеллы.. AST. gamedev. lsp. Open source. python. renpy. ruff. visual novel. Visual Studio. визуальные новеллы. Разработка игр.. AST. gamedev. lsp. Open source. python. renpy. ruff. visual novel. Visual Studio. визуальные новеллы. Разработка игр. статический анализ.

Как я полез фиксить русификатор для мода к DDLC, а написал для Ren’Py свой аналог Ruff со статанализом, проверкой сохранений, развилок и LSP.

Всё началось с чужих багов

Началась эта история максимально приземлённо: я сел допиливать кривущий русификатор для крупного мода DDLC Blue Skies. Машинный перевод там сам по себе был отдельным аттракционом, но очень быстро вскрылась проблема похуже: помимо кривого текста, мне пришлось тоннами выгребать технические багули самого сценария, которые тянулись ещё с оригинала.

Если вы хоть раз ковыряли моды на Ren’Py или пилили свою визуальную новеллу, то наверняка знаете эту классику: сценарий визуально выглядит чистенько, игра без проблем стартует, пара основных рутов прокликивается на ура… а где-то на десятом часу игры в глухой боковой ветке лежит обращение к переменной, которой в помине нет, прыжок в никуда или кривой экран, роняющий игру просто потому, что движок решил отрисовать его на пару кадров раньше.

Обычный способ ловить такое в Ren’Py — запускать игру и часами долго и муторно тыкать по развилкам, надеясь, что на этот раз кривой рут попадётся на глаза. Но когда в новелле под 20–30 часов текста со сложными ветвлениями, ручные тесты после каждого микропатча превращаются в пытку.

В обычной Python-разработке я очень привык к шустрому линтеру Ruff: он моментально бьёт по рукам за кривой код прямо в процессе набора текста. Захотелось ровно того же самого для Ren’Py. Пошёл искать готовые тулзы — и наткнулся на пустоту. Существующие расширения отлично справляются с подсветкой синтаксиса и базовыми сниппетами, но они работают на уровне текста: следят, чтобы двоеточия стояли где надо, но абсолютно слепы к семантике Ren’Py — к рассинхрону переменных в меню, механике rollback, утечкам в экранах и безопасности RPA-архивов.

В итоге я психанул и сел на коленке набрасывать свой семантический анализатор — Senpy (semantic + renpy, ну и заодно отсылка к senpai).

Да, по фану я зашил туда режим –tsundere, где линтер кроет тебя отборными фразами злой аниме-девочки за то, что ты забыл объявить дефолты или сломал механику отката. Но под этой шуткой лежит вполне взрослая инженерная задача: поймать фатальные косяки до того, как игра улетит в релиз и крашнется у живого игрока.

Что вообще умеет ловить Senpy?

По сути, Senpy делает то, чего не умеет встроенный движок: проверяет код на лету, не запуская Pygame и не заставляя ждать загрузки игры.

В отличие от плагинов с парой регулярок, он понимает внутреннюю кухню Ren’Py. Сейчас в нём 33 правила, и сгруппированы они по тем местам, где авторы новелл чаще всего стреляют себе в ногу:

  • Состояние, переменные и механика сохранений (RPY1xx) — ловит переменные без дефолтов, мутации констант и кривой рандом, ломающий откат назад.

  • Экраны и интерфейс (RPY2xx) — предиктивные мутации, кнопки-пустышки без action и тяжёлые питоновские циклы, превращающие UI в слайд-шоу.

  • Диалоги и поток исполнения (RPY3xx) — незакрытые теги {b}, прыжки на несуществующие метки и случайные провалы сюжета в чужие сцены.

  • Сюжетные развилки в меню (RPY4xx) — забытые флаги в выборах, пустые кнопки menu и взаимоисключающие условия.

  • Ассеты и медиафайлы (RPY5xx) — опечатки в спрайтах, потерянная музыка и заглядывание внутрь .rpa архивов.

  • Локализация (RPY6xx) — рассинхрон переменных в game/tl/, когда переводчик случайно сломал тег или переменную и игра вылетает с KeyError.

Сухой список правил из доки читать скучно, поэтому покажу на живых примерах, где авторы новелл чаще всего оставляют сами себе сюрпризы.

Почему я не стал дёргать нативный renpy lint?

Первая мысль любого, кто сталкивается с этой задачей: «Зачем писать свой велосипед, если в самом движке уже есть встроенная команда renpy.exe <путь> lint?»

Но если копнуть, как этот нативный линт работает, сразу становится грустно:

  1. Он ни разу не статический. Команда lint под капотом буквально запускает движок: поднимает скрытое окно Pygame, исполняет все init python блоки и реально крутит рантайм. На проекте на 70 000 строк этот процесс висит секунд по 30–40. Прикрутить такое к Language Server в VS Code, чтобы ошибки рисовались за 15 миллисекунд прямо во время набора текста — нереально в принципе.

  2. Он слеп к логике сохранений. Для нативного линтера объявление переменной на лету через $ money = 10 — это абсолютно легитимный Python. Движок не видит тут никакой ошибки, потому что в синтаксисе её нет. А вот то, что у игрока через неделю после релиза от этого развалятся сейвы, встроенную проверку не волнует.

  3. Гонять нативный lint в GitHub Actions — это оверхед: вам приходится тащить в CI многомегабайтный дистрибутив самого Ren’Py со всеми бинарниками и поднимать headless-окружение ради пары базовых проверок.

А почему тогда не Tree-sitter или ANTLR?

Второй резонный вопрос от любого матёрого разработчика: «Окей, пишешь статаназ — почему не взять готовые генераторы грамматик типа ANTLR, Lark или хотя бы Tree-sitter?»

Отвечаю: Полноценно подружить готовый генератор грамматик с движком, где ключевые слова могут динамически интерпретироваться как Python-выражения без чёткого контекста — это колоссальный оверхед, который для задачи статического анализа скриптов просто не окупается. Гибрид “стейт-машина + ast.parse” пишется за выходные и закрывает 99% потребностей без поддержки си-биндингов.

Синтаксис движка парсится его собственным самописным лексером, замешанным прямо на логике исполнения. Это безумный гибрид из кастомных ключевых слов и произвольного нативного питона, вкрученного посреди строк. Попытка формализовать всё это в честную EBNF-грамматику быстро превращается в адский долгострой: вы будете месяцами описывать правила, а на первом же мажорном обновлении Ren’Py всё сломается.

В итоге я выбрал прагматичный инженерный гибрид:

  • Для сценарной части (диалоги, переходы, экраны) — легковесная стейт-машина кавычек, склейка логических строк и профильные регулярки.

  • Для всего, где начинается Python (всё, что идёт после $, условия if/elif и блоки python:) — натравливаем стандартный питоновский модуль ast.parse.

Это дало сумасшедшую скорость: Senpy не строит избыточные километровые деревья там, где они не нужны, но в нужный момент разбирает код на реальные абстрактные узлы.


Архитектура: почему нельзя проверить всё за один проход?

В Ren’Py все файлы новеллы живут в одной гигантской общей помойке глобального пространства имён (store). Вы можете объявить метку label scene_2 или персонажа в chapter_1.rpy, а прыгать на неё из script.rpy.

Если вы попытаетесь анализировать сценарий построчно за один проход от первого файла к последнему, линтер моментально сойдёт с ума: он будет орать на каждую вторую переменную, потому что она объявляется в файле, до которого парсер ещё просто не дошёл.

Поэтому анализ строго разбит на два прохода:

  1. Первый проход (сбор символов): Senpy пролетает по всем файлам проекта и индексирует сущности: вытаскивает все метки, экраны, default, define, изображения, layeredimage и аудиоалиасы. На выходе получаем глобальные таблицы объявлений.

  2. Второй проход (прогон правил): Анализатор идёт по строкам заново, но теперь он знает контекст всего проекта. Проверяя jump chapter_two, он уже не гадает, а точно знает, есть ли такая метка хоть в одном файле сценария.

Стек областей видимости (ScopeStack)

Помимо глобального скоупа, в новелле есть локальные контексты: аргументы экранов, переменные в циклах for внутри UI, параметры меток. Если скидывать их в общую кучу, начнутся ложные срабатывания.

Чтобы разрулить эту кашу, мы внедрили ScopeStack — иерархический стек скоупов по принципу того, как это сделано в Mypy:

class ScopeStack:
    def __init__(self, builtins: set[str]):
        global_scope = Scope(ScopeType.GLOBAL, "global", -1)
        global_scope.symbols.update(builtins)
        self.scopes: list[Scope] = [global_scope]

    def add_symbol(self, var: str, is_constant: bool = False,
                   is_global: bool = False) -> None:
        target = self.global_scope if is_global else self.current
        target.symbols.add(var)
        if is_constant:
            target.constants.add(var)

    def is_declared(self, var: str) -> bool:
        return any(var in scope.symbols for scope in reversed(self.scopes))

    def is_constant(self, var: str) -> bool:
        return any(var in scope.constants for scope in reversed(self.scopes))

Всё работает на отступах:
Встретили объявление screen shop(page_idx): — пушим новый SCREEN-скоуп с отступом экрана и закидываем туда локальный аргумент page_idx. Отступ вернулся на уровень 0 — стек автоматически схлопывается обратно до глобального скоупа через pop_to_indent.

А reversed(self.scopes) ищет имена снизу вверх: сначала в локальном контексте экрана/цикла, а если не нашло — поднимается в глобальный store.


Где по-настоящему решает Python AST

Регулярками можно парсить синтаксис Ren’Py, но когда дело доходит до питоновских вставок, они ломаются об колено.

Банальный пример: в Ren’Py константы объявляются через define, и трогать их в коде нельзя — это наглухо ломает механику отката назад (Rollback). Если кто-то пишет $ MAX_HP += 10, линтер должен дать леща ошибкой RPY105.

Попробуйте поймать это регуляркой: вам придётся учитывать пробелы, экранирования, проверять, не в кавычках ли находится плюс, и отсекать ложные срабатывания. Через ast.parse это решается красиво и безошибочно — мы просто смотрим на узел AugAssign (составное присваивание):

elif isinstance(node, ast.AugAssign) and isinstance(node.target, ast.Name):
    var_name = node.target.id
    # Проверяем по нашему ScopeStack, не является ли переменная константой из define
    if var_name in self.analyzer.defined_constants:
        # Исключаем персонажей, у которых Character(...) может быть в define
        if var_name not in self.analyzer.declared_characters:
            self.analyzer._add_diagnostic(
                Diagnostic(
                    file_path=file_path,
                    line_number=real_line,
                    line_content=raw_l.strip(),
                    code="RPY105",
                    message=(
                        f"Constant '{var_name}' was declared with 'define', "
                        "but modified via '+='. Use 'default' for mutable variables."
                    ),
                    col=start_c,
                    end_col=end_c,
                ),
                noqa_map,
            )

Вместо гадания по тексту мы точно знаем: это именно операция +=, её цель — конкретная переменная, и мы можем мгновенно сверить её имя с базой констант.

Если собрать весь этот процесс воедино, пайплайн работы выглядит максимально прямолинейно:

flowchart TD
    A[Скрипты .rpy и ресурсы] --> B[Индексация ассетов и .rpa]
    B --> C[Проход 1: сбор глобальных символов]
    C --> D[Таблицы объявлений и скоупов]
    D --> E[Проход 2: пайплайн чекеров]
    E --> F[Анализ развилок menu и графа сцен]
    F --> G[Валидация файлов перевода tl/]
    G --> H[Готовые диагностики]
    H --> I[Вывод: CLI / JSON / LSP в VS Code]

Сначала быстро шерстим проект и архивы, собираем общую базу символов, а вторым проходом натравливаем на файлы пачку изолированных чекеров.

Senpy или зачем визуальной новелле семантический линтер, если в Ren’Py уже есть встроенная проверка? - 1

Грабли № 1. Переменная-призрак и сломанные сохранения (RPY104)

Самый подлый и распространённый класс багов в Ren’Py связан с тем, что движок слишком снисходителен к кривому коду при первом запуске.

Смотрите, как часто пишут новички:

label buy_item:
    $ money = 100
    $ money -= 25
    "Вы купили предмет."

Запускаем проект, прокликиваем сцену — всё работает идеально! Никаких ошибок в консоли, баланс уменьшается.

Но в Ren’Py есть фундаментальное правило: переменные, которые должны жить в сохранениях, обязаны объявляться через верхнеуровневый default:

default money = 100

label buy_item:
    $ money -= 25
    "Вы купили предмет."

В чём разница?
Переменные, объявленные на лету через $ money = 100, — бомба замедленного действия. Да, в пределах одной игровой сессии она может работать, но если вы выпустите патч с новой главой, старые сейвы игроков не будут знать об этой переменной и игра вылетит с NameError. А если игрок сделает Rollback назад дальше строчки её создания — переменная рассинхронизируется. default гарантирует, что переменная всегда зарегистрирована в движке независимо от версии сейва

Правило RPY104 отслеживает такие переменные-призраки и сразу орёт ошибкой: «Ты объявил переменную на лету, вынеси её в default, иначе похоронишь сейвы игроков».

Под эту же категорию подпадает целая пачка смежных грабель:

  • RPY102 — попытка сделать $ score += 10 до того, как переменная вообще где-то была создана. В моменте это чистый NameError.

  • RPY103 — проверка if has_key: по переменной, которой нет в проекте.

  • RPY105 — мутация константы из define. Константы создаются один раз при старте игры для неизменяемых вещей (шрифты, персонажи, статичные конфиги). Если менять их на ходу, движок гарантированно ломает откат назад.

  • RPY106 — использование стандартного питоновского import random или изменяемых множеств set(). Ren’Py не умеет отслеживать мутации нативных сетов при откате назад — вместо них нужно использовать списки [] или renpy.revertable_set(), а вместо обычного рандома — renpy.random.

Все эти вещи ловятся через разбор Python AST: мы смотрим не просто на текст, а на реальные узлы присваивания и типы переменных.


Лечим старые проекты: генератор default на автомате

Когда я натравил линтер на старые моды, там вылезло штук 80 предупреждений RPY104. Вручную сидеть и выписывать десятки строк default var = … в начале файлов — занятие для мазохистов.

Поэтому я прикрутил флаг –generate-defaults.

Работает просто: линтер находит все переменные без дефолтов, лезет в AST-дерево присваивания, вычисляет их базовый тип (числа сводит к 0, строки к “”, списки к [], словари к {}) и сам генерирует готовый файл senpy_defaults.rpy:

Senpy или зачем визуальной новелле семантический линтер, если в Ren’Py уже есть встроенная проверка? - 2
Senpy или зачем визуальной новелле семантический линтер, если в Ren’Py уже есть встроенная проверка? - 3

Понятно, что это не серебряная пуля: если переменная по логике сюжета должна стартовать со значения 100, а не 0, заглянуть глазами всё равно придётся. Но как инструмент скорой помощи, чтобы за 5 секунд оживить совместимость старых сохранений — экономит тонну времени.

Ловушка № 2. Экраны (Screen Language) и мины предикшена

Экраны в Ren’Py — это главное минное поле для тех, кто пришёл из обычного веба или GUI-разработки.

Смотришь на код экрана, видишь питоновские строчки и думаешь: «Ну функция и функция, вызовется при открытии менюшки».

И пишешь вот такое:

screen shop():
    $ player_gold -= 10
    text "Купить зелье"

А потом на тестах выясняется, что игрок просто открыл магазин, ничего не нажал, а у него уже списалось 40 монет. Откуда?!

Дело в том, что экраны в Ren’Py реактивные и предиктивные. Движок может в фоновом потоке по несколько раз перерисовывать и предварительно вычислять экран (predict screen), чтобы заранее подгрузить нужные картинки в память видеокарты ещё до того, как игрок их увидит.

Если вы пихнули прямую мутацию стейта ($ player_gold -= 10) прямо в тело экрана, эта строчка выполнится не по клику, а в процессе предикшена и будет непредсказуемо повторяться при каждом цикле перерисовки UI движком. Для изменения состояния в экранах существуют строгие Action (например, SetVariable), но новичкам движок об этом при запуске не сообщает.

Правило RPY201 сразу пресекает за любые мутации прямо в теле экрана.

Заодно чекер экранов ловит ещё пачку неочевидной дичи:

  • RPY206 — прямой вызов функции со скобками в экшене: action heal_player(). Из-за скобок Python выполнит функцию в момент отрисовки экрана, а не по клику! Правильно писать через обёртку: action Function(heal_player).

  • RPY203 — кнопки-пустышки (textbutton / imagebutton без параметра action). В игре они выглядят нормально, но при клике игрок просто тыкает в пустоту.

  • RPY205 — императивные циклы for/while на питоне внутри экрана. Они в ноль ломают кэширование отрисовки Ren’Py и превращают UI в тормозящее слайд-шоу (для списков в экранах есть нативный сценарный for item in items:).

  • RPY204 — вызов экрана, которого вообще нет в проекте.


Сюжетная дыра № 3 или ошибка Шрёдингера в развилках menu: (RPY401)

Сюжетные развилки — это место, где ручное тестирование почти всегда садится в лужу.

Классическая ситуация из реального мода:

label dungeon:
    menu:
        "Взять фонарь":
            $ has_torch = True
            "Вы подобрали фонарь."

        "Идти в темноте":
            "Вы решили рискнуть."

    # ... Спустя 2000 строк диалогов:
    if has_torch:
        "Фонарь разгоняет тьму."

Разработчик при тестах всегда выбирает первый вариант (он же его только что написал!). Всё работает как часы.

Игра уходит в релиз. Игрок выбирает «Идти в темноте», доходит до развилки через два часа игры — и игра начисто крашится с NameError: name ‘has_torch’ is not defined. Игрок ловит рейдж-квит и идёт писать гневный отзыв в Steam.

Правило RPY401 как раз и отслеживает такую асимметрию в развилках: линтер смотрит, какие переменные создаются в каждой ветке menu:. Если в одном выборе флаг дали, а во втором забыли, и при этом у переменной нет глобального default — Senpy моментально ткнёт пальцем в конкретную ветку выбора, где вы забыли инициализировать состояние.

Рядом работают ещё два правила:

  • RPY402 — пустые пункты меню, где забыли написать текст или прыжок (в игре такой пункт просто мгновенно схлопнется и провалится дальше).

  • RPY403 — заведомо бредовые условия вроде if False: или взаимоисключающие противоречия типа if has_key and not has_key:.


Сюжет как граф переходов: ловим утечки сцен (RPY3xx)

Сценарий визуальной новеллы нельзя читать просто как текст сверху вниз — это ориентированный граф из меток (label), прыжков (jump), вызовов (call) и возвратов (return).

В больших проектах на сотни тысяч слов сценаристы постоянно теряют концы веток. Senpy собирает все связи проекта в единый граф и подсвечивает структурные косяки:

  1. RPY308 (Fallthrough / Провал сцены) — когда сцена закончилась обычным текстом, а автор забыл поставить в конце jump или return. В Ren’Py управление в таком случае не остановится, а тупо провалится в следующую по файлу метку — герой внезапно телепортируется в чужую сцену.

  2. RPY306 (Call без возврата) — вызвали метку через call, а в конце неё нет return. Стек вызовов зависает, и возврат назад ломается.

  3. RPY302 — прыжок по опечатке на несуществующую метку (вместо падения игры на релизе линтер подскажет ближайшее совпадение: did you mean ‘chapter_2’?).

  4. RPY303 — мёртвые черновики сценария (метки, на которые нигде в проекте нет статических переходов).

Интерактивная карта сюжета прямо в VS Code

Сначала я просто сделал выгрузку этого графа в Markdown через флаг --graph-file story.md, чтобы смотреть его в сторонних вьюверах. Но переключаться между вкладками браузера и кодом — так себе удовольствие. Поэтому прикрутил генерацию графа прямо внутрь расширения VS Code через Webview:

Senpy или зачем визуальной новелле семантический линтер, если в Ren’Py уже есть встроенная проверка? - 4

Нажимаем Ctrl+Shift+P ➔ «Senpy: Show Story Graph» — и в соседней вкладке появляется интерактивная архитектурная карта всей новеллы, даже если в ней десятки тысяч строк кода.

Что уже умеет холст:

  1. Масштабирование и панорамирование: колёсиком мыши можно летать по гигантским полотнам с сотнями развилок и веток.

  2. Цветовая индикация проблем: линтер визуально красит опасные узлы: Оранжевые узлы — сцена закончилась без return или jump, сюжет гарантированно провалится в другую сцену (Fallthrough); Красные узлы — прыжки в пустоту на несуществующие метки.

  3. Клик с мгновенным прыжком в код: узлы на карте полностью кликабельны! Кликаешь по любой плашке сюжета — и VS Code в соседнем окне мгновенно открывает нужный файл и переносит курсор ровно на объявление этой метки. А если кликнуть по красному битому узлу — редактор телепортирует тебя прямо на строку со сломанным jump, где была допущена опечатка.

Ассеты: когда код идеален, но картинка испарилась (RPY5xx)

Сценарий в Ren’Py может быть синтаксически вылизан до блеска, но игра всё равно вылетит, если вы опечатались в имени аудиофайла или названии эмоции персонажа.

Senpy не просто читает код, он реально индексирует файловую структуру проекта: шерстит папки images/, audio/, служебные каталоги и сопоставляет их со сценарием:

  • RPY501 — музыка или звук не найдены на диске (игра встретит сцену гробовой тишиной или крашнется).

  • RPY502 — опечатка в спрайте или позе персонажа (show sayori 99z_pose). Причём линтер не просто скажет «не найдено», а заботливо предложит опечатку через нечёткий поиск: did you mean ‘1a’?

  • RPY503 — неиспользуемый мусор на диске. Картинка или трек лежат в папке, но в сценарии ни разу не вызываются. Для релизного билда в Steam это чистый мёртвый груз, раздувающий размер игры.

  • RPY504 — дубликат или случайная перезапись объявления картинки image.


Архивы .rpa и как не поймать RCE через чужой Pickle

А что делать, если вам принесли чужой мод или игру, где исходников .rpy нет вообще, а есть только архивы .rpa и скомпилированные .rpyc?

С архивами .rpa есть огромная неочевидная засада: исторически индекс файлов внутри RPA запакован обычным питоновским pickle.

Если вы пишете утилиту для анализа чужих модов и просто наивно вызовете pickle.loads() на скачанном из интернета файле — вы моментально получаете уязвимость выполнения произвольного кода (RCE) на своей машине. Злоумышленнику достаточно подсунуть в индекс архива шеллкод через reduce.

Поэтому в rpa.py я сделал двухуровневую защиту:

  1. Анализ байткода пикла: перед распаковкой поток байт прогоняется через pickletools.genops(). Если там встречается хоть один опкод, выходящий за рамки белого списка примитивных типов (числа, строки, списки, словари) — файл сразу летит в бан.

  2. Изолированный Unpickler: собственный класс переопределяет метод find_class() и намертво запрещает любые импорты модулей и вызовы системных функций.

А для одиночных скомпилированных файлов .rpyc линтер умеет на лету поднимать изолированную временную песочницу, автоматически выкачивать unrpyc с гитхаба и декомпилировать скрипты в фоне. Вам больше не нужно вручную расковыривать проект сторонними утилитами перед проверкой.

Senpy или зачем визуальной новелле семантический линтер, если в Ren’Py уже есть встроенная проверка? - 5

Локализация: почему переводчики ломают игры (RPY6xx)

С этого блока, собственно, и началась разработка Senpy, когда я ковырял перевод DDLC Blue Skies.

Переводчики новелл — это обычно филологи, а не программисты. Они работают с файлами в game/tl/<язык>/ и переводят блоки диалогов:

translate russian strings:
    old "Hello, [player_name]! Are you ready?"
    new "Привет, [player]! Ты готов?"  # Опечатка в переменной!

В оригинале движок ждёт переменную player_name, а в переводе человек опечатался и написал player.

Итог: игра на английском проходится идеально, а русская локализация ровно на этой реплике вылетает с фатальным KeyError. Найти такую опечатку глазами в файле на 50 000 строк — адский труд.

Senpy разбирает файлы перевода отдельным конечным автоматом (учитывая old/new, комментированные строки оригинала и служебные команды вроде voice или with) и сравнивает структуры через счётчики Counter:

  • RPY601 — рассинхрон переменных. Линтер моментально выдаст точный диф: Variable mismatch in translation (lost: [player_name]; typo/extra: [player]). Will crash with KeyError!

  • RPY602 — потерянные или сломанные теги стилей. Если в оригинале было {b}Внимание!{/b}, а переводчик случайно забыл закрыть тег {b}, весь дальнейший текст новеллы поплывёт жирным шрифтом.

  • RPY603 — пустые строки перевода (new “”). Игрок вместо диалога увидит немой пустой экран.

Почему не один гигантский файл: переезд на чекеры

Изначально анализатор внутри выглядел как типичный прототип, написанный на коленке: один гигантский цикл while на 400 строк в analyzer.py, куча разрозненных сетов для символов и постоянная путаница с тем, где мы сейчас находимся — в экране, в метке или внутри питоновского блока. Поддерживать такое и добавлять новые правила было мучительно больно.

В итоге мы разгрузили ядро и переехали на модульный Checker Pipeline.

Сейчас архитектура предельно прозрачная:

  • ScopeStack держит стек областей видимости (Global, Screen, Label, Block) и рулит отступами.

  • Сам analyzer.py превратился в компактный диспетчер на ~70 строк, который просто читает логические строки и прогоняет их через специализированные чекеры:

    • FormattingChecker — табуляции, фигурные кавычки из ворда и устаревший API (LiveComposite, ui.*).

    • ScreenChecker — всё, что касается UI, битых кнопок и скобок в вызовах экшенов.

    • AssetChecker — проверка путей к картинкам, звукам и атрибутов в layeredimage.

    • DialogueChecker — прыжки, провалы в метки и неэкранированные кавычки в репликах.

    • StateChecker — питоновские блоки, условия if/elif и развилки в меню.

Каждое правило теперь изолировано в своём классе: захотел добавить новую проверку для экранов — просто дописал метод в ScreenChecker, не боясь сломать парсер диалогов.


Из консоли прямо в редактор: пилим свой Language Server (LSP)

CLI-утилита в терминале — это здорово для автоматических проверок в GitHub Actions перед релизом. Но в реальной жизни никто не хочет после каждой написанной строчки переключаться в консоль и руками вбивать команду. Ошибки должны светиться прямо перед глазами во время набора текста.

Поэтому я прикрутил полноценный LSP-сервер на базе библиотеки pygls.

Писать тяжёлый плагин на JavaScript/TypeScript под VS Code я не стал — дублировать логику анализа на JS было бы безумием. Расширение для VS Code получилось максимально легковесным: это просто тонкая обёртка, которая запускает питоновский сервер Senpy в фоне и общается с ним по протоколу JSON-RPC.

Тут вскрылся забавный подводный камень: кодировка UTF-16.
В VS Code позиции курсора и диапазоны подчеркивания символов измеряются в 16-битных кодовых единицах UTF-16. Если в тексте новеллы попадаются эмодзи, японские иероглифы или специфические кавычки, обычный питоновский len() начинает безбожно врать, и красная волнистая линия съезжает мимо опечатки. Пришлось писать отдельный расчёт смещений get_utf16_range, чтобы подсветка попадала пиксель-в-пиксель.

Чтобы редактор не лагал при наборе текста, на изменения висит debounce-таймер на 150 мс: линтер не сканирует все тысячи строк на каждую нажатую клавишу, а берёт из памяти только изменённый файл и прогоняет инкрементальную проверку. На один файл уходит 10–15 миллисекунд:

Вся суть в одном тултипе: Senpy видит незакрытый тег {b} и предлагает автофикс по Ctrl+., пока популярный плагин на полном серьёзе считает тег форматирования объявлением персонажа (character) b

Вся суть в одном тултипе: Senpy видит незакрытый тег {b} и предлагает автофикс по Ctrl+., пока популярный плагин на полном серьёзе считает тег форматирования объявлением персонажа (character) b

Кстати, заливать расширение в официальный маркетплейс Microsoft я не стал — регистрация разработчика в Azure у мелкомягких это отдельный цирк с конями и бюрократией. Готовый собранный .vsix лежит в релизах на GitHub: скачал, нажал в VS Code «Install from VSIX» — и всё работает из коробки.


Автофикс по Ctrl+. и режим Цундере

Часть тупых механических ошибок Senpy умеет чинить сам — хоть через флаг –fix в консоли, хоть по нажатию Ctrl+. в редакторе:

  • Заменяет табы в отступах на 4 пробела (RPY001);

  • Вычищает типографские «умные» кавычки “ ” « » из кода (RPY002);

  • Автоматически дописывает закрывающие теги в диалогах вроде {/b} или {/color} прямо перед закрывающей кавычкой (RPY301);

  • Меняет устаревшие функции вроде LiveComposite на современный Composite (RPY107).

Но лезть в сюжетную логику автофиксом я намеренно не стал. Линтер может подсказать, что переменная забыта в развилке меню, но решать за автора, какое значение ей дать по сюжету — это верный способ всё поломать.

А вишенка на торте — флаг –tsundere.

Я зашил его чисто по фану, чтобы разбавить унылые логи линтера. В этом режиме сухие системные сообщения превращаются в реплики злой аниме-девочки:

Senpy или зачем визуальной новелле семантический линтер, если в Ren’Py уже есть встроенная проверка? - 7
Senpy или зачем визуальной новелле семантический линтер, если в Ren’Py уже есть встроенная проверка? - 8

Правила работают абсолютно те же самые, но когда линтер орёт на тебя за косяки с сохранениями — фиксить их становится как-то веселее.


Что по скорости?

Весь сценарий DDLC Blue Skies на 77 000 строк кода Senpy перелопачивает на холодном старте за ~3.5–3.9 секунды.

Секрет скорости простой: мы не инициализируем тяжеловесный рантайм Pygame, не грузим графическую подсистему и не гоняем тяжелые парсер-комбинаторы. Быстрая стейт-машина логических строк отсекает 80% лишней работы, а стандартный питоновский AST точечно отрабатывает только по нужным фрагментам.

Ложка дёгтя: где статический анализ бессилен

Чтобы не строить иллюзий: Senpy — это прикладной статический чекер, а не искусственный интеллект с хрустальным шаром. У статаназа в таком динамическом движке, как Ren’Py, есть очевидные границы:

  1. Динамический роутинг на питоне. Если в коде написано renpy.jump(“scene_” + str(chapter_id)) или путь к картинке склеивается из переменных во время игры — никакой статический парсер в мире не угадает, какая строка там получится в рантайме. На такие метки линтер может ругнуться как на неиспользуемые (RPY303), поэтому их стоит оценивать с учётом архитектуры вашего проекта.

  2. Не каждый варнинг — баг. Если вы пишете глобальный мод поверх чужой игры, намеренное переопределение картинок оригинальной новеллы — нормальная практика. Чтобы линтер не спамил, ненужные коды правил легко отключаются через конфиг senpy.toml или точечно прямо в строке через # noqa: RPY504.

  3. Художественную ценность текста он не оценит. Линтер понятия не имеет, насколько у вас интересный сюжет, логичные диалоги или хорошая драма. Он следит за технической частью: чтобы переменные не испарялись, кавычки не ломали парсер, кнопки нажимались, а сохранения не превращались в тыкву.

  4. Музейные экспонаты времён раннего Ren’Py 6.
    Поскольку Senpy крутится на современном рантайме, под капотом у него работает ast.parse из Python 3.
    С играми на Ren’Py 8 (включая свежие проекты, где всё до байта зашито в монолитный .rpa) и поздними на Ren’Py 7 / 6.99 всё работает отлично — тот же сценарий «Бесконечного Лета» и монструозный DDLC Blue Skies линтер проглатывает целиком.

    А вот на совсем древнем мамонте вроде оригинальной Katawa Shoujo (эпохи Ren’Py 6.12) линтер не справился. И дело даже не в дремучих конструкциях Python 2.7 вроде старого синтаксиса except Exception, e:. Главная беда в том, что в Катаве позы, эмоции и куча ассетов собираются процедурно прямо в рантайме через кастомные питоновские классы при запуске движка. Для статического анализатора таких картинок в коде просто не существует. Я честно пытался прикрутить туда эвристику, но понял: чтобы распарсить это без ошибок, пришлось бы запустить саму игру. Но тогда тут и Senpy уже не нужен.

    Да и тянуть совместимость с легаси десятилетней давности цели не стояло. Поэтому золотой стандарт для Senpy — это Ren’Py 6.99, 7 и 8. На самом деле можете пробовать и на более ранних версиях, но какой будет результат, я предсказать не могу. Если вдруг будете пробовать — отписывайтесь, будет интересно.

Senpy не заменяет ручное прохождение игры целиком, но он снимает с тестировщиков самую тупую и выматывающую рутину — отлов тупых крашей и опечаток.


Как пощупать на своём проекте

Ядро линтера оформлено в виде обычного Python-пакета. Имя senpy на PyPI, увы, уже занято древним анализатором тональности текста, который давно заброшен, поэтому пакет называется senpy-linter, но в консоль регистрирует родной короткий бинарник:

pip install senpy-linter

(Короткое имя senpy на PyPI оказалось занято чужой древней библиотекой, поэтому ставим именно senpy-linter, но запускается всё привычной командой senpy).

Чтобы проверить свой проект или распакованный мод, натравливаем команду на папку game:

senpy "путь/к/проекту/game"

Или через cmd из папки /game нужной игры просто пишем senpy

Полезные флаги:

  • –fix — на автомате закрывает забытые теги {b}…{/b}, вычищает кривые типографские кавычки и заменяет табы на 4 пробела.

  • –generate-defaults — находит все переменные, созданные на лету через $, и сам генерирует файл senpy_defaults.rpy, спасая совместимость старых сейвов.

  • –graph-file story.md — выгружает полную карту переходов между сценами в формате Mermaid.

  • –errors-only — скрывает предупреждения и показывает только критические косяки, которые гарантированно уронят игру.

  • –ignore RPY104 RPY303 — отключает конкретные проверки, если они не вписываются в архитектуру вашего мода.

  • –tsundere — включает режим злой аниме-девочки для поднятия настроения.

Если работаете в VS Code — забирайте готовый собранный плагин .vsix со всеми фичами LSP прямо из раздела Releases на GitHub.

Исходники, документация по всем 33 правилам и баг-трекер:
👉 https://github.com/Xilleet/senpy


Что в итоге

Всё начиналось с банального желания поправить чужой перевод и не сойти с ума от ручных тестов, а вылилось в полноценный инструмент, без которого я разработку на Ren’Py уже не представляю.

Главная фишка для меня даже не в количестве правил и не в цундере-ругательствах. Кайф в том, что ты больше не боишься выкатывать обновление: тебе не нужно часами прокликивать каждую развилку сценария, чтобы убедиться, что глупая опечатка трёхмесячной давности в забытой ветке не сломает игроку прохождение на финальном титре.

Если вы делаете свои визуальные новеллы или пилите моды на Ren’Py — прогоните Senpy на своих скриптах просто ради интереса.

Если наткнётесь на ложные срабатывания, странный синтаксис, который линтер не переварил, или захотите предложить свои правила — велкам в Issues на Гитхабе или прямо тут в комментарии.

Автор: xilleet

Источник