Вход на сайт

Просмотр новости

Найдите то, что Вас интересует

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

Дата публикации: 23-10-2026 14:46:54

Как попытка пофиксить русификатор для мода к DDLC вылилась в разработку собственного семантического анализатора для Ren'Py со скоростью 77 000 строк за 3.8 секунды, собственным ScopeStack, AST-парсером и LSP-сервером для VS Code.Рассказываю, почему встроенный renpy lint бесполезен на CI, как неочевидные предиктивные рендеры экранов воруют золото у игроков, почему переменные без default гарантированно убивают сохранения после релизных патчей и как защититься от RCE при распаковке чужих .rpa архивов. Читать далее

Основное содержимое страницы с новостью.

Уровень сложностиСредний

Время на прочтение19 мин

Охват и читатели1.3K

Кейс

Как я полез фиксить русификатор для мода к 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]

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

0dfc88bcba01df523a1ace6d01a1c4dd.png
Грабли № 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:

733c140bb90c894f2e4a896a0f161c76.png09858f201bd0875cfcde390f007c4f49.png

Понятно, что это не серебряная пуля: если переменная по логике сюжета должна стартовать со значения 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:

187accc421584594daffd39502ec58b2.gif

Нажимаем 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 с гитхаба и декомпилировать скрипты в фоне. Вам больше не нужно вручную расковыривать проект сторонними утилитами перед проверкой.

5b5d0af92cbb37db5d8ffbacda2a9518.png
Локализация: почему переводчики ломают игры (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.

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

901e6b1a846dd5d1314a4694e4b98b2a.pngef4979739028ad00858fc93bc62d2e43.png

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


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

Весь сценарий 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 на Гитхабе или прямо тут в комментарии.

Схожие новости

#Наименование новостиТональностьИнформативностьДата публикации
1Как индексация строк изменила систему типов HydraScript08.0617-10-2026
2Ты не найдёшь эту ошибку. Потому что её нет в твоём коде. Как Self-describing API спасает от чужих рефакторингов5807-07-2026
3Бенчмаркая ключи Dictionary: забыл IEquatable — получил ×5 и 96 байт на каждый поиск19.1325-07-2026
4Scrutor: логирование отменённых HTTP-запросов09.5720-10-2026
5А нам точно нужны code agents?0517-07-2026
6Один файл, одна команда: как мы упростили запуск dev-окружения с помощью Runium012.1124-07-2026
7DPI для любопытствующих09.5809-08-2026
8AGR.Checker: что нового в плагине для проверки ЦИМ перед сдачей в АГР04.9628-09-2026
9[Перевод] Возвращение аспектно-ориентированного программирования08.403-07-2026
10Зря вы не используете эти HTML-элементы010.6315-09-2026

Классификация: Мнения. Схожих патентов: 0. Схожих новостей: 10. Тональность: 0. Информативность: 12.7. Источник: habr.com.