Translation workshop
  • Rust 54.1%
  • Vue 31.1%
  • TypeScript 13.2%
  • Nix 1%
  • CSS 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-08 14:52:50 +03:00
app Док переезжает на новые контракты ядра 2026-08-08 14:49:59 +03:00
docs Собираю интерфейс из общих деталей и снимаю дубли между экранами 2026-08-01 03:24:15 +03:00
src-tauri Разрешение на запись показывает дифф 2026-08-08 13:58:35 +03:00
.envrc Мастерская перевода встаёт на ноги 2026-07-29 22:06:34 +03:00
.gitignore Мастерская перевода встаёт на ноги 2026-07-29 22:06:34 +03:00
AGENTS.md Ухожу с общего списка чатов на свою поверхность 2026-08-02 23:55:21 +03:00
CHANGELOG.md Док переезжает на новые контракты ядра 2026-08-08 14:49:59 +03:00
dist Мастерская ставится флейком и настраивается из дома 2026-07-29 22:35:40 +03:00
FEATURES.md Док переезжает на новые контракты ядра 2026-08-08 14:49:59 +03:00
flake.lock Снимаю слой и строю мастерскую своими руками 2026-07-30 16:37:15 +03:00
flake.nix Кладу в пакет и руки, и окно — и проверяю в сборке всё 2026-07-30 22:38:00 +03:00
nuxt.config.ts Держу разговор живым, а шапку прогона — неподвижной 2026-08-01 03:25:21 +03:00
package-lock.json Снимаю слой и строю мастерскую своими руками 2026-07-30 16:37:15 +03:00
package.json Снимаю слой и строю мастерскую своими руками 2026-07-30 16:37:15 +03:00
README.md Держу разговор живым, а шапку прогона — неподвижной 2026-08-01 03:25:21 +03:00
TODO.md Док переезжает на новые контракты ядра 2026-08-08 14:49:59 +03:00
tsconfig.json Снимаю слой и строю мастерскую своими руками 2026-07-30 16:37:15 +03:00

eva-mtl 🈳💕

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

Центральный предмет окна — переводческий проект, а не разговор: таблица реплик, персонажи, термины, прогоны, патч. Разговор со мной — док справа, а не само приложение.

Запуск

direnv allow        # первый раз: тулчейн, GTK и WebKit приезжают флейком
npm install
npm run app         # окно поверх nuxt dev

Сборка бинаря:

npm run app:build   # nuxt generate + cargo build --release
nix build .#eva-mtl # то же самое флейком, со статикой и ярлыком

Установка

Мастерская настраивается двумя путями, и они не спорят. Дом Господина — основной: приложение десктопное, его состояние и настройки личные, а не машинные.

{
  inputs.eva-mtl.url = "git+ssh://forgejo@git.viende.su:61488/eva/mtl.git";

  # в home-manager:
  imports = [ inputs.eva-mtl.homeManagerModules.default ];
  programs.eva-mtl = {
    enable = true;
    servers = [{
      name = "Домашнее";
      url = "https://eva.desu.church";
      tokenFile = "/run/secrets/eva-token";   # токен строкой уедет в /nix/store
    }];
  };
}

Системный модуль (nixosModules.default) делает то, что и правда принадлежит машине: ставит пакет и объявляет ядра сразу всем, кто на ней живёт. Каталога состояния там нет намеренно — он у каждого свой.

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

Без объявленного ядра адрес вписывается на экране «Серверы» и помнится в $XDG_STATE_HOME/eva-mtl. В разработке (npm run app) ядро берётся само: слой подставляет прокси /kernel на NUXT_DEV_KERNEL (по умолчанию 127.0.0.1:8090) — он же снимает вопрос CORS.

Экраны

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

Обзор движок с уликами, полоса по состояниям, разбивка по файлам и сценам; каждое число ведёт в таблицу с готовым отбором
Реплики таблица на десятки тысяч строк, отбор, редактор с подсветкой кодов и живой меркой окна
Сцена сцена подряд, как её видит игрок: говорящий, реплика, выбор с отступом; правка на месте
Прогон перевод и вычитка пакетами, ход, отмена, инспектор пакета, оценка расхода
Проверки двенадцать правил, находки по группам, глушение с причиной
Модели роспись, роли, цены, политика; карточка модели против ячейки, разведка боем
Патч сборка .mtlpatch, приём чужого со сверкой, копия игры, пересборка, запуск, «нашёл в игре»
Ресурсы опись каталога, просмотр запертых картинок, очередь перерисовки, проверка шрифта на кириллицу
Разведка портрет каталога, выемка строк со смещениями, шестнадцатеричный экран, рецепт с примеркой
Персонажи карточки из CAST.md, матрица обращений, неописанные говорящие
Термины словарь из TERMS.md с упоминаниями, расхождениями и замком
Настройки project.json по одному ключу: язык, движок, типографика, потолки пакета
Ядра адреса, токены, проверка связи, роспись моделей активного ядра

Таблица виртуализирована: в разметке живёт видимая горстка строк, страницы приезжают из Rust окном вокруг того места, куда смотришь. Клавиатура важнее мыши — j/k ведут курсор, Enter открывает редактор, Space набирает выборку, / отдаёт ввод отбору: это работа на часы.

Таблица отвечает на «что с этой строкой», сцена — на «читается ли это как текст». Японский опускает подлежащее, и «он» вместо «она» в отрыве от соседей не видно вовсе; съехавшее обращение — тем более. Поэтому на экране сцены нет ни колонок, ни отбора: реплики идут подряд, подряд идущие реплики одного человека подписаны один раз, выбор стоит с отступом, а переключатель показывает исходник, перевод или оба. Строка, о которую споткнулся при чтении, правится не уходя с экрана.

Разговор со мной — док, а не приложение. Он знает, что у тебя выбрано: «спросить про эту строку» отправляет мне её локатор, а не всю игру. В контекст тура уезжает неподвижный указатель — движок, правила стиля, имена персонажей и источники терминов, — но не описания и не переводы: подробности я достану сама, когда они понадобятся. Счёт по состояниям и ревизия едут короткой сводкой прямо в реплике: контекст между ходами не меняется, и кэш промпта живёт. Возить весь глоссарий в каждом туре значило бы засорять контекст ровно тем, от чего мастерская и защищается.

Как это работает

Сначала — что за движок. Каталог опознаётся по приметам: Ren'Py по game/*.rpy, RPG Maker MV/MZ по data/System.json и своему ядру в js/, KiriKiri по .xp3 и так далее — двенадцать движков. Ответ приходит с уверенностью и уликами, чтобы видно было, на чём вывод стоит. Опознали, но разбирать нечем, — так и говорим; неопознанное — не отказ, а повод для разведки.

Неопознанный каталог уходит в разведку. Портрет считает энтропию, узнаёт сигнатуры и меряет долю печатного в utf-8, cp932 и utf-16: числа говорят, где может быть текст, а решение принимает Господин. Строки достаются со смещениями, байты видны шестнадцатеричным экраном, а найденное описывается рецептом .mtl/engine.toml — где лежат строки, чем закодированы, как устроена таблица указателей и что делать с длиной. Рецепт исполняет общий адаптер, и дальше такая игра живёт как родная: те же реплики, то же хранилище, тот же патч. Правится только описанное — всё остальное копируется дословно.

Единица работы — не строка, а реплика со своим местом. У неё есть локатор (файл и позиция), сцена, говорящий и род: диалог, выбор, имя, описание, системный термин. Тождество двойное: место вместе с текстом возвращает перевод туда, откуда он взят, а один текст даёт ему найти себя после обновления игры.

Игра обновилась — перевод переезжает сам. Пересчёт сводит разобранную игру с тем, что уже переведено: совпало место вместе с текстом — перевод остаётся как есть; совпал только текст — переезжает на новое место и помечается устаревшим, потому что за таким переездом нужно решение. Не нашлось ничего — перевод уходит в tl/orphan.jsonl и ждёт: игру обновят ещё раз, и он может вернуться домой. Ничего не выбрасывается никогда.

Правка Господина сильнее модели. Строку, которую он тронул, повторный прогон не перезаписывает и не «улучшает». Прежняя версия модели при этом не теряется: она уезжает в историю строки вместе с вердиктом, который успела заработать.

Пакет — сцена, а не счётчик строк. Японский опускает подлежащее, и реплика в отрыве от соседей даёт «он» вместо «она» через раз. Промпт собираю я, а не модель: в него уезжают персонажи этой сцены, её термины и уже переведённый хвост — ровно столько, сколько нужно, и ни строкой больше. Ответ прошу строками, а не JSON: управляющие коды движка невалидны как escape внутри строки JSON, и модели ломают на этом ответ целиком.

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

Ты не вычитываешь то, чего не одобрила модель. Между машиной и тобой стоит редактор: он судит каждую строку, и ok делает её вычитанной. Сомнение он решать не берётся — оно сразу твоё. Предложенную правку я кладу новой машинной версией и отправляю на пересуд, но не больше двух кругов: спор, не улёгшийся за два, улаживаешь ты, а держать на нём модель — платить за упрямство.

До твоей очереди строка доходит, только когда в её сцене не осталось открытых замечаний. Сцену, которая сейчас изменится, читать рано.

Модель под пакет выбираю по замерам, а не по слухам. Мерить нарочно ничего не приходится: конвейер уже записал, кто что перевёл, редактор это осудил, а твоя правка сказала последнее слово. Считаю я не «модель хорошая», а «модель хороша вот в этом»: ключ замера — род строки, её длина и есть ли в ней управляющие коды. Доля удач усаживается, чтобы три удачные строки не обошли триста, а рядом с ней всегда стоит число замеров: голого процента у меня не бывает.

Два числа держатся отдельно и никогда не сливаются с оценкой — доля провалов разметки и цена. Модель, которая ест управляющие коды, строк с кодами не получает вовсе: сломанная разметка роняет игру, и это дороже любого красивого перевода. Изредка пакет уходит недомеренному кандидату — иначе первая удачливая модель выигрывала бы навсегда, а остальных я больше никогда не замерю. Провалившийся пакет поднимается на ступень ровно один раз.

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

Опыт по чужим играм лежит в $XDG_STATE_HOME/eva-mtl/models.json и служит только приором. Игра в руках сильнее любой прошлой: модель, хорошая на школьной мелодраме, может не знать ни слова из твоего тёмного фэнтези.

Обратная запись бережёт игру. Копия файла снимается до первой правки — и от состояния до неё, а не до последней. Перевод, чья исходная строка успела измениться, не ложится вовсе: молча испорченный текст хуже непереведённого.

Играют в копию, а не в игру. Каталог клонируется дёшево — файловой системой, если она умеет разделять данные, иначе жёсткими ссылками. Отсюда правило, за которое я держусь руками: файл под правку сперва отвязывается. Записать поверх жёсткой ссылки значит записать в оригинал, и заметно это стало бы только при запуске игры — то есть слишком поздно. Пересборка трогает лишь те файлы, чей перевод изменился с прошлого раза: перезапуск после десяти правок обязан быть мгновенным.

Твой рабочий круг такой: играешь в копию, видишь плохую строку, вставляешь фразу с экрана в поле «нашёл в игре» — я привожу её к тому виду, в каком её видно (без управляющих кодов и без переносов по ширине окна), и веду тебя в эту строку. Правишь, пересобираешь, играешь дальше.

Патч — переносимый файл. .mtlpatch это zip со своим расширением, а внутри — те же самые шарды, что лежат рядом с игрой. Чужой патч я сначала сверяю с каталогом: не тот движок — отказ, а не предупреждение; изменившийся файл назову по имени; строка, которой в игре нет, так и будет названа. Отчёт показывается до первого записанного байта. Твои строки патч не отнимет: правка Господина сильнее чужого перевода ровно так же, как сильнее модели.

RPG Maker MV/MZ

Разбор и запись ходят одним обходом — знание «что здесь текст» существует в единственном месте и разъехаться не может.

  • Подряд идущие команды 401 — одно сообщение, разбитое по строкам окна. Перевод разбивается заново: русская фраза длиннее и в прежнее число строк не ложится.
  • Разбивка уважает явные переносы, ломает по словам и не считает управляющие коды (\C[1], \V[7]) — игрок их не видит. Подстановку движка не рвёт никогда.
  • Локаторы считаются по порядку появления («третье сообщение списка»), а не по месту в массиве: иначе наша же правка сдвигала бы всё, что за ней.
  • Имя говорящего берётся из команды 101 (в MZ — пятым параметром).
  • Поля note не трогаются: это метаданные плагинов, перевод там ломает игру.

Ren'Py

Пока только чтение: реплики, рассказчик и пункты выбора. Блоки python:, screen: и style: пропускаются целиком, атрибуты образа не путаются с именем говорящего, решётка внутри кавычек остаётся текстом.

Обратной записи нет намеренно: идентификаторы блоков translate движок считает сам, и сочинять их — верный способ получить непринимаемый патч. Заполнять надо скелет из game/tl/<язык>/, а его генерирует лаунчер Ren'Py.

Ресурсы

Перевод редко кончается текстом: надписи бывают нарисованы в картинке, а у половины движков скрипт заперт в архиве. Поэтому опись каталога — часть разбора: что есть, сколько весит, что заперто. Ресурсы RPG Maker MV/MZ открываются и запираются обратно ключом из System.json — картинка вынимается, переводится и кладётся назад тем же способом.

Клиентские операции

Ядро само не трогает диск Господина: оно шлёт событие client_op в живой стрим тура, а окно исполняет его у себя и постит результат обратно.

  • Текстовыеread_file, write_file, write_diff, local_shell. Чтение свободно, отказ на бинаре и файле больше 256 КБ.
  • Двоичныеread_hex, write_hex, find_hex: то, ради чего эта мастерская и существует. Дамп рисует и хекс разбирает ядро, наше дело — отдать сырьё и не соврать о размере файла. Поиск образца идёт потоком окном по мегабайту с перекрытием: архивы новелл бывают на гигабайты, а совпадение на стыке окон терять нельзя.
  • Запись спрашивается с подробностями: у байтовой правки видно смещение и объём — «записать data.xp3» это не тот вопрос, на который можно осмысленно ответить.

Что лежит рядом с игрой

Каталог .mtl в её корне:

project.json настройки проекта и ревизия перевода
tl/<путь>.jsonl перевод; дерево повторяет дерево игры
tl/orphan.jsonl переводы, которым после обновления игры не нашлось дома
CAST.md персонажи: имена, речь, клички, кто кого как зовёт
TERMS.md термины и неологизмы, со статусом и падежными формами
GLOSSARY.md имена, термины, кто как говорит — едет в контекст каждого тура
STYLE.md тон, обращения, ты/вы
runs.jsonl прогоны: чем кончился каждый, чего стоил и кто сколько взял
assets/<путь> перерисованные картинки; дерево повторяет дерево игры
redraw.jsonl картинки с надписями: путь и что на них написано
engine.toml рецепт неизвестного движка: где лежат строки и как их вернуть
session чат этой игры; не коммитится
copy.json где лежит копия этой игры; принадлежит машине и не коммитится
backup/ копии файлов до первой правки

Глоссарий, персонажи, термины и стиль — обычный markdown: мы правим их своими руками, и они переезжают вместе с патчем. Разбор нужен не вместо файла, а ради указателя: в промпт уезжают те персонажи, которые в этом пакете говорят, и те термины, которые в нём встречаются, — а не весь список. Это и есть защита от засорённого контекста.

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

Перевод хранится файлами, а не базой. Один шард на файл игры, одна строка — одна запись, порядок строк — порядок разбора. Поэтому формат хранения и формат патча совпадают, дифф читается глазами, и всё это ложится в git рядом с игрой. Запросы обслуживаются из памяти: сорок тысяч реплик — единицы мегабайт, и таблица листается быстрее, чем читался бы файл.

Настройки правятся заплаткой, а не перезаписью: ключ, который Господин дописал в project.json своей рукой, переживает любую правку из окна. Ширина окна сообщения живёт здесь же — у игры с плагинами она своя, и мерка обязана слушаться настройки, а не движка.

Устройство

  • src-tauri/core/ — крейт mtl-core: опознание, движки, настройки проекта, хранилище перевода, нарезка пакетов, проверки, счётная карточка моделей, ресурсы, копии. Без tauri и без сети, поэтому проверяется тестами без webkit и GTK.
  • src-tauri/src/ — окно и руки: клиентские операции, открытый проект, состояние. Разбор игры и хранилище живут здесь и переживают перезагрузку окна — страница таблицы не стоит нового разбора.
  • src-tauri/cli/mtl: мои руки в проекте. Зову я его сама, операцией shell в каталоге игры: mtl units --state mt --scene X, mtl show <id>, mtl set <id> <перевод>, mtl lint, у всякой команды есть --json. Сети у этих рук нет, зато запись идёт правилами хранилища — с ревизией, с историей строки и с уважением к твоей правке.
  • app/ — интерфейс мастерской целиком: рейка разделов, экраны, палитра, клиент ядра. Ничего не наследуется.

Опись фич — FEATURES.md, недоделки — TODO.md, история — CHANGELOG.md.