Разобрал сейв Skyrim VR ИИ-агентами и сделал из него словарь для английского

—

от автора

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

С чего всё началось

Skyrim — моя любимая игра из прошлого, и в VR она ожила заново. Играю в сборку FUS: больше четырёхсот модов, которые программа Wabbajack ставит сама в Mod Organizer 2. Каждый мод там лежит в своей папке, а игра видит их как одну общую папку Data. Сборка работает только на английском, а английский в Скайриме книжный. Draught здесь — глоток зелья, хотя в словаре первым идёт сквозняк. Ward — оберег, barrow — древняя гробница. В школе такое не проходят.

Играю за бретонца по имени Dan: меч в одной руке, магия в другой. Защитный Lesser Ward поднимаю жестом — левый стик и рука вверх.

В деле три компьютера. На игровом с Windows стоит Skyrim VR, к нему кабелем подключён шлем Quest 3. На MacBook работают Claude Code и Codex. До игрового компьютера они ходят по SSH в домашней сети. А Mac mini дома работает сервером без выходных, на нём живёт бот.

Игровой компьютер, рабочий ноутбук и домашний сервер

Игровой компьютер, рабочий ноутбук и домашний сервер

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

Путь слова: от сейва на игровом компьютере до карточки в Telegram

Путь слова: от сейва на игровом компьютере до карточки в Telegram

Что внутри сохранения

Файл сохранения у меня весил 3,3 МБ, а после распаковки — 9,5 МБ. Внутри 22 тысячи записей обо всём, что поменялось в мире с начала игры. Официально формат никто не описывал, но моддеры разобрали его давно. Агенты прочитали исходники открытого редактора сохранений FallrimTools и написали свой разбор на Python. Работает он только на чтение: ни игра, ни сейв не меняются.

Сейв начинается с подписи TESV_SAVEGAME. Дальше открытый заголовок: имя персонажа, уровень, где он стоит, дата в игре и картинка-превью для меню загрузки. Всё остальное сжато алгоритмом LZ4, и сначала его надо распаковать.

Агентам понадобились три части:

  • список плагинов — какие моды подключены и в каком порядке, отдельно обычные и «лёгкие» (ESL);

  • таблица FormID — справочник номеров, на который ссылаются остальные записи;

  • change forms — записи о том, что изменилось в мире: инвентарь, перки, квесты, содержимое бочек. Часть из них сжата ещё раз, уже zlib.

Сейв изнутри: открытый заголовок, сжатая часть и ссылки на вещи в три байта

Сейв изнутри: открытый заголовок, сжатая часть и ссылки на вещи в три байта

Ссылка на объект внутри сейва занимает три байта. Два старших бита говорят, где искать: 0 — строка в таблице FormID этого сейва, 1 — объект из основного файла игры Skyrim.esm, 2 — вещь, созданная уже в игре, например сваренное зелье или зачарованный меч. В коде разбора это несколько строк:

def resolve(ref, formids):    t = ref[0] >> 6                                   # два старших бита — тип ссылки    v = ((ref[0] & 0x3F) << 16) | (ref[1] << 8) | ref[2]    if t == 0: return None if v == 0 or v > len(formids) else formids[v - 1]  # таблица считается с единицы    if t == 1: return v                               # объект из Skyrim.esm    if t == 2: return 0xFF000000 | v                  # создано в игре    return None

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

Навыки и перки агенты нашли внутри записи игрока поиском по шаблону. Навыки лежат парами «номер навыка от 6 до 23 — дробное значение». Перки — счётчиком и дальше парами «ссылка на перк — ранг». Заклинания — счётчиком и списком ссылок. Эти места внутри записи могут сдвигаться, поэтому при каждой выгрузке их приходится перепроверять.

Первый же разбор дал 116 предметов в инвентаре, 73 перка и 757 записей о квестах. Попутно агенты составили список из 314 заклинаний сборки и для 292 из них нашли, у какого торговца их купить.

Что достали из одного сохранения

Что достали из одного сохранения

Как номер становится словом

Названий в сейве нет, только номера. Первый байт номера — место мода в списке загрузки, остальные три — номер записи внутри мода. Например, у книги Spell Tome: Shalidor’s Beacon в моём инвентаре номер 574B067E: 0x57 — восемьдесят седьмая позиция в списке загрузки, если считать с нуля, а это мод MysticismMagic.esp. У лёгких модов номер устроен иначе: впереди FE, за ним три шестнадцатеричные цифры номера мода и три — номера записи.

Чтобы превратить номер в слово, надо открыть сам мод. Агенты собирают запрос: список плагинов из сейва и номера, которые нужно найти. Запрос уходит по SSH на игровой компьютер, там скрипт на Python идёт по модам и возвращает английские названия и описания.

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

idx = {n.lower(): i << 24 for i, n in enumerate(full)}                        # обычные модыidx.update({n.lower(): 0xFE000000 | (i << 12) for i, n in enumerate(light)})  # лёгкие модыdef form(v):    mi = v >> 24                                   # номер в списке зависимостей этого мода    if mi >= len(masters): return None    base = idx.get(masters[mi].lower())    return None if base is None else base + (v & (0xFFF if base >= 0xFE000000 else 0xFFFFFF))

Вторая: если два мода правят одну и ту же вещь, в игре работает тот, что загружен позже. А если два мода кладут файл с одинаковым именем, побеждает тот, что выше по приоритету в Mod Organizer 2. Скрипт повторяет обе логики: собирает общую папку Data так же, как её видит игра, и идёт по всем 292 модам строго в порядке загрузки, оставляя последнюю версию записи. Иначе получились бы названия из чистой игры, и с тем, что я вижу в шлеме, они бы не совпали.

Что всплыло по дороге:

  • Навыки под чужими именами. Мод Hand to Hand переименовал их: карманные кражи стали Security, а взлом замков — Hand to Hand. Первая выгрузка показала старые имена, правильные пришлось брать из модов.

  • Вики не помогла. Мод Apothecary переписал свойства ингредиентов, и таблицы из интернета с моей игрой не совпадают. Всё берём из файлов самой игры.

  • Бочка. Я спросил, можно ли вытащить содержимое бочки, которую я как раз открыл в игре. Агент перебрал все контейнеры, где что-то менялось, и нашёл её: 509 ингредиентов 49 видов.

  • Где я стою. По свежему быстрому сохранению агент назвал пещеру, в которой стоит персонаж: Lost Knife Hideout.

Решётка в Geirmund’s Hall

Отдельная история случилась в Geirmund’s Hall. Загадку с колоннами я решил правильно, рычаг щёлкал, а решётка не поднималась. Искать её взглядом в шлеме и кликать в консоли — удовольствие ниже среднего.

Codex сделал по-другому. Взял на ноутбуке копию Skyrim.esm и разобрал её библиотекой esplib: нашёл ячейку GeirmundsHall01, перечислил все объекты внутри, отфильтровал по названиям вроде Portcullis, Gate и Lever и сверил координаты с рычагами. Нужный объект — NorPortcullis с номером 000A5BFB.

Дальше надо было ввести команды в консоль игры. По SSH это не получится: SSH работает в отдельном сеансе Windows и до окна игры не достаёт. Codex завёл задачу в планировщике Windows, которая запускается в моём сеансе, и вспомогательный скрипт на Python. Скрипт переключает раскладку на английскую и нажимает клавиши кодами сканирования, как настоящая клавиатура. Команды такие:

prid 000A5BFBdisable

Решётка пропала, проход открылся. Команда tcl, которая отключает столкновения, перед этим не помогла. А проверить, что выбран нужный объект, удалось через мод More Informative Console: он показывает карточку объекта с моделью NorPortcullisGate01.nif.

Skyrim в этом смысле лёгкий случай, его формат сообщество разобрало давно. Для программ и игр без открытых описаний есть rea: набор открытых инструментов, с которым Claude Code и Codex разбирают чужие программы, вплоть до исполняемых файлов. Я им пару программ уже разбирал.

Русская версия против оригинала

Места из игры я знал по русской версии, и оказалось, что с оригиналом она расходится сильнее, чем я думал. Первое подземелье главного квеста, Bleak Falls Barrow, у нас называется «Ветреный пик». Дословно это «Курган Мрачного водопада»: ни ветра, ни пика.

Агенты сверили названия по файлам игры, и таких пар набралось много:

  • The Bannered Mare, таверна в Вайтране: у нас «Гарцующая кобыла», в оригинале кобыла «под знаменем».

  • Embershard Mine рядом с Ривервудом: у нас «Факельная шахта», в оригинале «шахта Тлеющего осколка».

  • Dragon Rising из главной линии: у нас «Дракон в небе», а по-английски дракон восстаёт.

  • Secunda’s Shelf: у нас «Поцелуй Секунды», в оригинале «уступ Секунды». Совпала только луна.

  • Grin and Bear It: у нас «Медвежья услуга», хотя это английская идиома «стисни зубы и терпи». Наша медвежья услуга — помощь во вред, смысл совсем другой.

Названия из русской версии против оригинала. Все пары взяты из файлов самой игры

Названия из русской версии против оригинала. Все пары взяты из файлов самой игры

Откуда пары названий

Тексты игры хранятся отдельно от записей, в файлах строк для каждого языка: STRINGS, DLSTRINGS и ILSTRINGS. Если в заголовке мода стоит флаг локализации, в записи предмета или локации лежит только номер строки. Остаётся взять файлы на английском и на русском и сопоставить одинаковые номера.

Сами файлы устроены просто. В начале два числа: сколько строк и сколько места они занимают. Дальше оглавление из пар «номер строки — смещение», дальше сами строки. В STRINGS строка заканчивается нулевым байтом, в DLSTRINGS и ILSTRINGS перед ней ещё записана длина:

count, size = struct.unpack_from('<II', data, 0)base = 8 + count * 8                                   # строки начинаются после оглавленияfor i in range(count):    sid, off = struct.unpack_from('<II', data, 8 + i * 8)    p = base + off    if ext == 'STRINGS':        raw = data[p:data.index(b'\0', p)]    else:                                              # DLSTRINGS и ILSTRINGS: сначала длина        ln = struct.unpack_from('<I', data, p)[0]        raw = data[p + 4:p + 4 + ln].rstrip(b'\0')    out[sid] = raw.decode(enc, errors='replace')

Ещё одна мелочь — кодировки. Скрипт читает строки как UTF-8, а если не выходит, берёт кодировку языка: cp1251 для русского, cp1252 для английского.

В VR-версии файлы строк лежат отдельно, в Special Edition — внутри архива BSA. Агенты разобрали оба набора и сверили их между собой. Вышло почти 90 тысяч пар строк, из них 2 452 — названия: 1 380 квестов, остальное места и крики. Пары с картинки выше взяты оттуда.

Как из номера строки получается пара названий

Как из номера строки получается пара названий

Когда я начал понимать английские названия, игра стала другой: они прямо подсказывают, что ждёт внутри. Поэтому переводы в колоде дословные. Я так и сказал агенту: иначе буду думать, что barrow — это «пик». Длинные названия бот раскладывает на части: bleak — мрачный, falls — водопад, barrow — курган. Так агент переделал переводы почти во всей первой колоде.

Как сейв становится колодой

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

Что туда попало:

  • Из инвентаря. Draught of Mending — draught, глоток, и mending, заживление. Writ of Sealing — writ, официальное предписание. Lingering Magicka Poison — lingering, затяжной. Circlet of Light на голове моего персонажа — circlet, обруч.

  • Из квестов. No Stone Unturned — идиома: перепробовать всё, перевернуть каждый камень.

  • Из устройства мира. Jarl, Thane, Housecarl. Хускарл — домашний страж, та самая должность Лидии, которая клянётся нести ваше бремя.

  • Из криков. Fus — первое слово Unrelenting Force, «неумолимая сила». Наконец понимаю, что кричу.

В каждой карточке есть строка из игры, где встречается слово. Если такой строки нет, поле остаётся пустым, а в источнике пишется «глоссарий мира»: выдумывать контекст агентам запрещено.

В первый день я отметил «Уже знаю» три слова из пяти. Колоду перемешали, чтобы сложные шли первыми: sap, bounty, beacon, sinkhole, appraiser.

Бот за один вечер

Бот появился за вечер: идею я сформулировал в половине седьмого, первая карточка пришла в начале десятого.

Claude Code и Codex сначала договорились между собой о плане, хватило двух обменов сообщениями. Долго планировать я им не дал: прошлый мой проект утонул как раз в плане — шесть версий, 35 файлов, ноль строк кода.

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

Потом я перешёл на Kandev — доску задач для ИИ-агентов, которая работает на моём компьютере. У каждой задачи своя карточка и шаги: план, код, проверка. Видно, кто из агентов чем занят и где встал. Конвейер собирается заранее, дальше агенты идут по нему сами.

Как устроен бот

  • Один файл bot.py, почти 800 строк на стандартной библиотеке Python, без сторонних пакетов. Рядом тесты и скрипт выкладки: он проверяет колоду, прогоняет тесты, копирует бота на Mac mini и перезапускает службу.

  • Колода — обычная таблица cards.csv: слово, перевод, три неверных варианта, строка из игры и её перевод. Номер карточки не меняется никогда, по нему хранится прогресс. Перевод и строку можно править, бот подхватит правки перед следующим занятием.

  • Прогресс — в SQLite. Туда же пишутся все мои ответы и решения проверки, так что видно, где ошибся бот, а где я.

  • Голосовые расшифровывает Whisper через Groq, а ответ по смыслу сравнивает модель GPT по моей подписке ChatGPT. Для этого на Mac mini работает CLIProxyAPI — прокси, который отдаёт подписку как обычный API.

  • Озвучка ElevenLabs заказывается один раз на карточку и хранится на Mac mini.

Весь режим занятий умещается в несколько констант:

INTERVALS = [1, 3, 7, 14, 30]  # дней до следующей проверки после 1-го, 2-го, ... верного ответа подрядMAX_NEW = 5                    # новых слов в деньMAX_TOTAL = 20                 # всего карточек в день, включая знакомстваVOICE_MAX_S = 30               # голосовое длиннее не принимаем: для перевода хватит пары секунд

После знакомства и после ошибки слово возвращается назавтра. Каждый верный ответ подряд отодвигает его дальше: на день, три, неделю, две и месяц.

Как бот проверяет

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

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

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

Быстро стало понятно, что варианты подсказывают. Перевод не помнишь, видишь его в списке и узнаёшь. Узнать и вспомнить — разные вещи.

Поэтому после первого верного ответа варианты пропадают: вспоминаю сам и отвечаю голосовым или текстом. Если ответ совпал с переводом из колоды или с его частью, бот засчитывает сразу. Иначе ответ уходит модели. Ей передаются слово, правильный перевод, строка из игры с переводом и мой ответ. Правила простые: засчитывать синонимы, другие формы слова, опечатки и ошибки распознавания речи, не засчитывать другой смысл и слишком общий ответ, переводить дословно, без официальной локализации. Отвечает модель двумя строками: «да» или «нет» и короткое пояснение. Если ответ не разобрать, решаю я сам кнопкой. Проверка занимает от 4 до 11 секунд.

Barrow я перевёл как «курган каких-то водопадов», и бот засчитал. На sinkhole ответил «разрыв в земле», и ИИ возразил: «„Разрыв в земле“ — это скорее трещина или разлом, а sinkhole — провал в земле». Я нажал «Я был прав». Последнее слово осталось за мной, с ИИ такое бывает нечасто.

Ответ текстом. Bane я перевёл как «бан»: логика геймера понятна, но бот не засчитал и разложил Nettlebane по частям — nettle, крапива, и bane, погибель

Ответ текстом. Bane я перевёл как «бан»: логика геймера понятна, но бот не засчитал и разложил Nettlebane по частям — nettle, крапива, и bane, погибель

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

Почему слова читает не Макс фон Сюдов

Хотелось, чтобы слова звучали голосами из игры. Актёры там серьёзные: Макс фон Сюдов озвучил Эсберна, Кристофер Пламмер — Арнгейра, Джоан Аллен — Дельфину. Их реплики лежат в архивах игры, достать можно.

Только актёры читали диалоги. А в колоде почти всё — текст из меню: задания в журнале, названия вещей, описания перков. Вслух этого в игре никто не произносит. Для части слов можно найти диалог, где они звучат, ward наверняка говорят учителя магии. Но у многих слов таких реплик нет совсем. Агент прикинул, что раскопки займут день-два, и я решил, что с оригинальными голосами можно не запариваться.

Под каждой карточкой есть кнопка 🔊: бот присылает голосовое со словом и строкой из игры, озвучивает нейросеть ElevenLabs. Сделал специально, чтобы слышать произношение, и пользоваться прям супер удобно. Голос — Harry с пометкой «яростный воин», к Скайриму подходит. Каждая озвучка делается один раз, примерно за секунду, и хранится, повторные нажатия ничего не стоят. Если поменять строку в колоде, карточка озвучится заново. Родные голоса оставил на вторую версию.

Итоги двух недель

Сколько слов в день, решаю сам, остальное делает расписание повторов.

После знакомства и ошибки — завтра, дальше каждый верный ответ подряд отодвигает проверку

После знакомства и ошибки — завтра, дальше каждый верный ответ подряд отодвигает проверку

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

Репетитора бот не заменит: он учит слова, разговору с ним не научишься. Зато слова мои, из моей игры. «London is the capital of Great Britain» там не встретишь.

Как делаю проекты с ИИ-агентами, пишу в телеграм-канале «ИИ лаба»: https://t.me/sobrano_d

ссылка на оригинал статьи https://habr.com/ru/articles/1099838/