- Rust 54.1%
- Vue 31.1%
- TypeScript 13.2%
- Nix 1%
- CSS 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| app | ||
| docs | ||
| src-tauri | ||
| .envrc | ||
| .gitignore | ||
| AGENTS.md | ||
| CHANGELOG.md | ||
| dist | ||
| FEATURES.md | ||
| flake.lock | ||
| flake.nix | ||
| nuxt.config.ts | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| TODO.md | ||
| tsconfig.json | ||
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.