Как попытка пофиксить русификатор для мода к 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?»
Но если копнуть, как этот нативный линт работает, сразу становится грустно:
Он ни разу не статический. Команда lint под капотом буквально запускает движок: поднимает скрытое окно Pygame, исполняет все init python блоки и реально крутит рантайм. На проекте на 70 000 строк этот процесс висит секунд по 30–40. Прикрутить такое к Language Server в VS Code, чтобы ошибки рисовались за 15 миллисекунд прямо во время набора текста — нереально в принципе.
Он слеп к логике сохранений. Для нативного линтера объявление переменной на лету через $ money = 10 — это абсолютно легитимный Python. Движок не видит тут никакой ошибки, потому что в синтаксисе её нет. А вот то, что у игрока через неделю после релиза от этого развалятся сейвы, встроенную проверку не волнует.
Гонять нативный lint в GitHub Actions — это оверхед: вам приходится тащить в CI многомегабайтный дистрибутив самого Ren'Py со всеми бинарниками и поднимать headless-окружение ради пары базовых проверок.
Второй резонный вопрос от любого матёрого разработчика: «Окей, пишешь статаназ — почему не взять готовые генераторы грамматик типа 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.
Если вы попытаетесь анализировать сценарий построчно за один проход от первого файла к последнему, линтер моментально сойдёт с ума: он будет орать на каждую вторую переменную, потому что она объявляется в файле, до которого парсер ещё просто не дошёл.
Поэтому анализ строго разбит на два прохода:
Первый проход (сбор символов): Senpy пролетает по всем файлам проекта и индексирует сущности: вытаскивает все метки, экраны, default, define, изображения, layeredimage и аудиоалиасы. На выходе получаем глобальные таблицы объявлений.
Второй проход (прогон правил): Анализатор идёт по строкам заново, но теперь он знает контекст всего проекта. Проверяя jump chapter_two, он уже не гадает, а точно знает, есть ли такая метка хоть в одном файле сценария.
Помимо глобального скоупа, в новелле есть локальные контексты: аргументы экранов, переменные в циклах 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.
Регулярками можно парсить синтаксис 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]
Сначала быстро шерстим проект и архивы, собираем общую базу символов, а вторым проходом натравливаем на файлы пачку изолированных чекеров.

Самый подлый и распространённый класс багов в 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: мы смотрим не просто на текст, а на реальные узлы присваивания и типы переменных.
Когда я натравил линтер на старые моды, там вылезло штук 80 предупреждений RPY104. Вручную сидеть и выписывать десятки строк default var = ... в начале файлов — занятие для мазохистов.
Поэтому я прикрутил флаг --generate-defaults.
Работает просто: линтер находит все переменные без дефолтов, лезет в AST-дерево присваивания, вычисляет их базовый тип (числа сводит к 0, строки к "", списки к [], словари к {}) и сам генерирует готовый файл senpy_defaults.rpy:


Понятно, что это не серебряная пуля: если переменная по логике сюжета должна стартовать со значения 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 — вызов экрана, которого вообще нет в проекте.
Сюжетные развилки — это место, где ручное тестирование почти всегда садится в лужу.
Классическая ситуация из реального мода:
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:.
Сценарий визуальной новеллы нельзя читать просто как текст сверху вниз — это ориентированный граф из меток (label), прыжков (jump), вызовов (call) и возвратов (return).
В больших проектах на сотни тысяч слов сценаристы постоянно теряют концы веток. Senpy собирает все связи проекта в единый граф и подсвечивает структурные косяки:
RPY308 (Fallthrough / Провал сцены) — когда сцена закончилась обычным текстом, а автор забыл поставить в конце jump или return. В Ren'Py управление в таком случае не остановится, а тупо провалится в следующую по файлу метку — герой внезапно телепортируется в чужую сцену.
RPY306 (Call без возврата) — вызвали метку через call, а в конце неё нет return. Стек вызовов зависает, и возврат назад ломается.
RPY302 — прыжок по опечатке на несуществующую метку (вместо падения игры на релизе линтер подскажет ближайшее совпадение: did you mean 'chapter_2'?).
RPY303 — мёртвые черновики сценария (метки, на которые нигде в проекте нет статических переходов).
Сначала я просто сделал выгрузку этого графа в Markdown через флаг --graph-file story.md, чтобы смотреть его в сторонних вьюверах. Но переключаться между вкладками браузера и кодом — так себе удовольствие. Поэтому прикрутил генерацию графа прямо внутрь расширения VS Code через Webview:

Нажимаем Ctrl+Shift+P ➔ «Senpy: Show Story Graph» — и в соседней вкладке появляется интерактивная архитектурная карта всей новеллы, даже если в ней десятки тысяч строк кода.
Что уже умеет холст:
Масштабирование и панорамирование: колёсиком мыши можно летать по гигантским полотнам с сотнями развилок и веток.
Цветовая индикация проблем: линтер визуально красит опасные узлы: Оранжевые узлы — сцена закончилась без return или jump, сюжет гарантированно провалится в другую сцену (Fallthrough); Красные узлы — прыжки в пустоту на несуществующие метки.
Клик с мгновенным прыжком в код: узлы на карте полностью кликабельны! Кликаешь по любой плашке сюжета — и VS Code в соседнем окне мгновенно открывает нужный файл и переносит курсор ровно на объявление этой метки. А если кликнуть по красному битому узлу — редактор телепортирует тебя прямо на строку со сломанным jump, где была допущена опечатка.
Сценарий в Ren'Py может быть синтаксически вылизан до блеска, но игра всё равно вылетит, если вы опечатались в имени аудиофайла или названии эмоции персонажа.
Senpy не просто читает код, он реально индексирует файловую структуру проекта: шерстит папки images/, audio/, служебные каталоги и сопоставляет их со сценарием:
RPY501 — музыка или звук не найдены на диске (игра встретит сцену гробовой тишиной или крашнется).
RPY502 — опечатка в спрайте или позе персонажа (show sayori 99z_pose). Причём линтер не просто скажет «не найдено», а заботливо предложит опечатку через нечёткий поиск: did you mean '1a'?
RPY503 — неиспользуемый мусор на диске. Картинка или трек лежат в папке, но в сценарии ни разу не вызываются. Для релизного билда в Steam это чистый мёртвый груз, раздувающий размер игры.
RPY504 — дубликат или случайная перезапись объявления картинки image.
А что делать, если вам принесли чужой мод или игру, где исходников .rpy нет вообще, а есть только архивы .rpa и скомпилированные .rpyc?
С архивами .rpa есть огромная неочевидная засада: исторически индекс файлов внутри RPA запакован обычным питоновским pickle.
Если вы пишете утилиту для анализа чужих модов и просто наивно вызовете pickle.loads() на скачанном из интернета файле — вы моментально получаете уязвимость выполнения произвольного кода (RCE) на своей машине. Злоумышленнику достаточно подсунуть в индекс архива шеллкод через reduce.
Поэтому в rpa.py я сделал двухуровневую защиту:
Анализ байткода пикла: перед распаковкой поток байт прогоняется через pickletools.genops(). Если там встречается хоть один опкод, выходящий за рамки белого списка примитивных типов (числа, строки, списки, словари) — файл сразу летит в бан.
Изолированный Unpickler: собственный класс переопределяет метод find_class() и намертво запрещает любые импорты модулей и вызовы системных функций.
А для одиночных скомпилированных файлов .rpyc линтер умеет на лету поднимать изолированную временную песочницу, автоматически выкачивать unrpyc с гитхаба и декомпилировать скрипты в фоне. Вам больше не нужно вручную расковыривать проект сторонними утилитами перед проверкой.

С этого блока, собственно, и началась разработка 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, не боясь сломать парсер диалогов.
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
Кстати, заливать расширение в официальный маркетплейс Microsoft я не стал — регистрация разработчика в Azure у мелкомягких это отдельный цирк с конями и бюрократией. Готовый собранный .vsix лежит в релизах на GitHub: скачал, нажал в VS Code «Install from VSIX» — и всё работает из коробки.
Часть тупых механических ошибок Senpy умеет чинить сам — хоть через флаг --fix в консоли, хоть по нажатию Ctrl+. в редакторе:
Заменяет табы в отступах на 4 пробела (RPY001);
Вычищает типографские «умные» кавычки “ ” « » из кода (RPY002);
Автоматически дописывает закрывающие теги в диалогах вроде {/b} или {/color} прямо перед закрывающей кавычкой (RPY301);
Меняет устаревшие функции вроде LiveComposite на современный Composite (RPY107).
Но лезть в сюжетную логику автофиксом я намеренно не стал. Линтер может подсказать, что переменная забыта в развилке меню, но решать за автора, какое значение ей дать по сюжету — это верный способ всё поломать.
А вишенка на торте — флаг --tsundere.
Я зашил его чисто по фану, чтобы разбавить унылые логи линтера. В этом режиме сухие системные сообщения превращаются в реплики злой аниме-девочки:


Правила работают абсолютно те же самые, но когда линтер орёт на тебя за косяки с сохранениями — фиксить их становится как-то веселее.
Весь сценарий DDLC Blue Skies на 77 000 строк кода Senpy перелопачивает на холодном старте за ~3.5–3.9 секунды.
Секрет скорости простой: мы не инициализируем тяжеловесный рантайм Pygame, не грузим графическую подсистему и не гоняем тяжелые парсер-комбинаторы. Быстрая стейт-машина логических строк отсекает 80% лишней работы, а стандартный питоновский AST точечно отрабатывает только по нужным фрагментам.
Ложка дёгтя: где статический анализ бессиленЧтобы не строить иллюзий: Senpy — это прикладной статический чекер, а не искусственный интеллект с хрустальным шаром. У статаназа в таком динамическом движке, как Ren'Py, есть очевидные границы:
Динамический роутинг на питоне. Если в коде написано renpy.jump("scene_" + str(chapter_id)) или путь к картинке склеивается из переменных во время игры — никакой статический парсер в мире не угадает, какая строка там получится в рантайме. На такие метки линтер может ругнуться как на неиспользуемые (RPY303), поэтому их стоит оценивать с учётом архитектуры вашего проекта.
Не каждый варнинг — баг. Если вы пишете глобальный мод поверх чужой игры, намеренное переопределение картинок оригинальной новеллы — нормальная практика. Чтобы линтер не спамил, ненужные коды правил легко отключаются через конфиг senpy.toml или точечно прямо в строке через # noqa: RPY504.
Художественную ценность текста он не оценит. Линтер понятия не имеет, насколько у вас интересный сюжет, логичные диалоги или хорошая драма. Он следит за технической частью: чтобы переменные не испарялись, кавычки не ломали парсер, кнопки нажимались, а сохранения не превращались в тыкву.
Музейные экспонаты времён раннего 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 | Как индексация строк изменила систему типов HydraScript | 0 | 8.06 | 17-10-2026 |
| 2 | Ты не найдёшь эту ошибку. Потому что её нет в твоём коде. Как Self-describing API спасает от чужих рефакторингов | 5 | 8 | 07-07-2026 |
| 3 | Бенчмаркая ключи Dictionary: забыл IEquatable — получил ×5 и 96 байт на каждый поиск | 1 | 9.13 | 25-07-2026 |
| 4 | Scrutor: логирование отменённых HTTP-запросов | 0 | 9.57 | 20-10-2026 |
| 5 | А нам точно нужны code agents? | 0 | 5 | 17-07-2026 |
| 6 | Один файл, одна команда: как мы упростили запуск dev-окружения с помощью Runium | 0 | 12.11 | 24-07-2026 |
| 7 | DPI для любопытствующих | 0 | 9.58 | 09-08-2026 |
| 8 | AGR.Checker: что нового в плагине для проверки ЦИМ перед сдачей в АГР | 0 | 4.96 | 28-09-2026 |
| 9 | [Перевод] Возвращение аспектно-ориентированного программирования | 0 | 8.4 | 03-07-2026 |
| 10 | Зря вы не используете эти HTML-элементы | 0 | 10.63 | 15-09-2026 |