
Animal Crossing известна своими очаровательными, но довольно однообразными диалогами. Запустив снова эту классику с GameCube, я был поражён (нет) тем, что спустя 23 года жители города говорят те же самые фразы. Надо это исправить.
В чём заключается проблема? Игра работает на Nintendo GameCube — 24-летней консоли с процессором PowerPC на 485 МГц, 24 МБ ОЗУ и полным отсутствием подключения к Интернету. Приставка фундаментально, философски и физически проектировалась как офлайновая.
В статье я расскажу историю о том, как проложил мостик из 2001 года в современность, сделав так, чтобы винтажная игровая консоль могла общаться с облачным ИИ, и не поменяв при этом ни строки кода оригинальной игры.
Первое препятствие: общение с игрой
Мне сразу же безумно повезло. На той же неделе, когда я приступил к своему проекту, завершилась масштабная работа сообщества, занимающегося декомпиляцией Animal Crossing. Поэтому мне не пришлось глазеть на ассемблер PowerPC — у меня был доступ к удобочитаемому коду на C.
Роясь в исходниках, я быстро обнаружил нужные мне функции в файле m_message.c
. Это было оно — сердце диалоговой системы. Благодаря простому тесту я убедился, что могу перехватывать вызов функции и заменять внутриигровой текст собственной строкой.
C: фрагмент из декомпилированной диалоговой системы
// Фрагмент декомпилированного исходного кода Animal Crossing
// Функция, меняющая данные сообщений в диалоговой системе.
// Моя начальная точка входа для замены текста.
extern int mMsg_ChangeMsgData(mMsg_Window_c* msg_p, int index) {
if (index >= 0 && index < MSG_MAX && mMsg_LoadMsgData(msg_p->msg_data, index, FALSE)) {
msg_p->end_text_cursor_idx = 0;
mMsg_Clear_CursolIndex(msg_p);
mMsg_SetTimer(msg_p, 20.0f);
return TRUE;
}
return FALSE;
}
Лёгкая победа, правда? Но менять статичный текст — это одно, а как в реальном времени передавать данные из внешнего ИИ в игру?
Первым делом я подумал о простом добавлении сетевого вызова. Но для этого понадобилось бы написать для GameCube с нуля весь сетевой стек (TCP/IP, сокеты, HTTP) и интегрировать его в игровой движок, совершенно на это не рассчитанный. Абсолютно не вариант.
Потом я подумал о том, чтобы использовать функции эмулятора Dolphin для записи в файл на моей хост-машине. Игра будет записывать файл «запроса» с контекстом, мой скрипт на Python будет считывать его, вызывать LLM и записывать файл «ответа». К сожалению, мне не удалось заставить песочницу GameCube получить доступ к файловой системе хоста. Ещё один тупик.
Прорыв: почтовый ящик памяти
Решением стала классическая техника, применяемая в моддинге игр: Inter-Process Communication (IPC) при помощи общей памяти. Смысл заключается в следующем: распределяем блок ОЗУ GameCube в качестве «почтового ящика». Внешний скрипт на Python может записывать данные непосредственно по этому адресу памяти, а игра — их считывать.
Python: ядро интерфейса «почтового ящика памяти»
# Это мост. Эти функции выполняют чтение и запись в ОЗУ GameCube через Dolphin.
GAMECUBE_MEMORY_BASE = 0x80000000
def read_from_game(gc_address: int, size: int) -> bytes:
"""Reads a block of memory from a GameCube virtual address."""
real_address = GAMECUBE_MEMORY_BASE + (gc_address - 0x80000000)
return dolphin_process.read(real_address, size)
def write_to_game(gc_address: int, data: bytes) -> bool:
"""Writes a block of data to a GameCube virtual address."""
real_address = GAMECUBE_MEMORY_BASE + (gc_address - 0x80000000)
return dolphin_process.write(real_address, data)
Шаг вперёд сделан. Но возникла ещё одна трудная задача: я должен был стать археологом памяти — найти стабильные адреса памяти текста активного диалога и имени говорящего.
Для этого я написал на Python собственный сканер памяти. Процесс был следующим:
Разговариваем с жителем города. Как только появляется диалоговое окно, я ставлю эмулятор на паузу.
Сканируем. Запускаем мой скрипт, сканирующий все 24 миллиона байт ОЗУ GameCube в поисках строки текста на экране (например, «Hey, how's it going?»).
Перекрёстная ссылка. Часто скрипт возвращает при этом несколько адресов. Поэтому я снимаю эмулятор с паузы, говорю с другим жителем и выполняю сканирование его имени, чтобы понять, какой блок памяти относится к говорящему.
Спустя несколько часов болтовни, пауз эмулятора и сканирования я наконец обнаружил ключевые адреса: 0x8129A3EA
— это имя говорящего, а 0x81298360
— буфер диалога. Теперь я мог точно определять, кто говорит и, что более важно, записывать данные в диалоговое окно.
А что насчёт GameCube Broadband Adapter? ?
Да, у GameCube был официальный широкополосный адаптер (Broadband Adapter, BBA). Но Animal Crossing была выпущена без сетевых примитивов, сокетов и использующего их протокола в слое игры. Для работы с BBA мне бы понадобилось создать небольшой сетевой стек и патчить игру, чтобы вызывать его. То есть перехватывать места вызова движка, планировать асинхронный ввод-вывод и обрабатывать повторные попытки/таймауты. И всё это внутри кодовой базы, которая вообще не была рассчитана на существование сети.
Хуки движка: перехватывать точки в цикле сообщений для отправки/получения пакетов.
Драйвер/протокол: реализовать минимальный интерфейс UDP/RPC через BBA.
Надёжность: обрабатывать таймауты, повторные попытки и частичное чтение данных без замораживания анимаций/UI.
Я решил использовать почтовый ящик в ОЗУ, потому что он детерминирован, не требует никакой работы с ядром/драйвером и остаётся полностью в границах эмулятора, не требуя двоичного сетевого стека. Тем не менее, вполне можно реализовать оболочку для BBA (и это будет интересный проект на будущее для реального железа на основе Swiss + homebrew).
#include <stdint.h>
/* Минимальный RPC-конверт для гипотетической оболочки BBA */
typedef struct {
uint32_t magic; // 'ACRP'
uint16_t type; // 1=запрос, 2=ответ
uint16_t length; // длина полезной нагрузки
uint8_t payload[512];
} RpcMsg;
int ac_net_send(const RpcMsg* msg); // отправка через BBA
int ac_net_recv(RpcMsg* out, int timeoutMs); // опрос с таймаутом
UDP-мост на стороне хоста (крайне упрощённый):
import socket, json
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", 19135))
while True:
data, addr = sock.recvfrom(2048)
msg = json.loads(data.decode("utf-8", "ignore"))
# ... вызываем LLM сценариста/режиссёра ...
reply = json.dumps({"ok": True, "text": "Hi from the cloud!"}).encode()
sock.sendto(reply, addr)
Говорим на тайном языке игры
Я попробовал записать «Hello World» по адресу диалога и... игра зависла. Анимации персонажей воспроизводились, но диалог не продолжался. Цель была так близка и так далека.
Проблема заключалась в том, что я отправлял текст без форматирования. Animal Crossing не разговаривает таким текстом. Она общается на своём закодированном языке с управляющими кодами.
Это похоже на HTML. Браузер в компьютере не просто отображает слова, он интерпретирует тэги, например, <b>
, делающий текст полужирным. В Animal Crossing всё устроено так же. Специальный префиксный байт CHAR_CONTROL_CODE
сообщает движку игры: «следующий байт — это не символ, а команда!»
Эти команды управляют всем: цветом текста, паузами, звуковыми эффектами, эмоциями персонажей и даже завершением беседы. Если не отправить управляющий код <End Conversation>
, то игра просто бесконечно будет ждать команды, которая никогда не поступит. И поэтому зависнет.
Здесь мне на помощь снова пришло сообщество, занимающееся декомпиляцией. Его участники уже задокументировали большинство кодов, мне оставалось лишь создать инструменты для их использования.
Я написал на Python кодировщик и декодер. Декодер считывает сырую память игры и преобразует её в человекочитаемый формат, а кодировщик может брать мой текст с тэгами и преобразовывать его в последовательность байтов, понятную GameCube.
# Небольшой пример управляющих кодов, которые мне нужно было кодировать/декодировать
CONTROL_CODES = {
0x00: "<End Conversation>",
0x03: "<Pause [{:02X}]>", # например, <Pause [0A]> для короткой паузы
0x05: "<Color Line [{:06X}]>", # например, <Color Line [FF0000]> для красного цвета
0x09: "<NPC Expression [Cat:{:02X}] [{}]>", # Срабатывание эмоции
0x59: "<Play Sound Effect [{}]>", # например, <Play Sound Effect [Happy]>
0x1A: "<Player Name>",
0x1C: "<Catchphrase>",
}
# Магический байт, сигнализирующий по поступлении команды
PREFIX_BYTE = 0x7F
Создав этот кодировщик, я повторил попытку. На этот раз я не просто отправлял текст, а говорил на языке игры. И это сработало. Самая сложная часть хака готова.
Создаём ИИ-мозг
Создав канал связи, можно было переходить к самому интересному: изготовлению ИИ.
Изначально я задумывал, что одна LLM будет делать всё: писать диалог, оставаться в образе и вставлять технические управляющие коды. В результате получился хаос. ИИ пытался быть одновременно творческим сценаристом и техническим программистом; и то, и другое у него получалось одинаково плохо.
Тогда я разбил задачу на конвейер из двух моделей: сценариста и режиссёра.
ИИ-сценарист: единственная задача этой модели — быть творческой. Она получает подробное описание персонажа (которое я сгенерировал, выполнив скрейпинг фанатской вики Animal Crossing) и пишет интересный диалог, оставаясь в образе и релевантно контексту.
ИИ-режиссёр: эта модель получает от сценариста необработанный текст. Её работа полностью техническая, она считывает диалог и решает, как «снимать сцену». Добавляет паузы для драматичности, выделяет слова цветом и выбирает самое подходящее выражение лица или звуковой эффект, соответствующий настроению.
Такое разделение обязанностей сработало идеально.
Эмерджентное поведение
Сначала я добавил в конвейер легковесный новостной фид. Спустя считанные мгновения жители города начали вплетать в свои беседы заголовки новостей; никаких промптов, только контекст.

Затем я дал им небольшую общую память для сплетен: кто что сказал, кому и как он прореагировал. Естественно, всё это вылилось в движение против Тома Нука [прим. пер.: персонаж игры, владеющий магазином и продающий игроку дом в ипотеку].

И мне напомнили, что в качестве новостного фида я выбрал Fox News.

Теперь игра стала более странной, забавной и немного тревожной.
Весь код проекта, в том числе интерфейс памяти, кодировщик диалогов и логика промптинга ИИ выложена на GitHub. Это был один из самых сложных и интересных моих проектов, объединивший в себе реверс-инжиниринг, ИИ и сильную любовь к классической игре.
Полное видео можно посмотреть здесь: Modern AI in a 24-Year-Old Game
Rive
Когда я увидел на ютубе, как игрок таскает за собой начальника на разборки в канцелярию Сейда Нин или играет в поле чудес в подвале гильдии магов Балморы, я осознал что живу в будущем.
Хотя, конечно, игровые LLM пока ещё сильно глючат.