Купи: «Божечки, Джош :)! Мне приснился самый странный сон: как будто всё, что мы делаем, происходит в игре! Гав!»
Купи: «Божечки, Джош :)! Мне приснился самый странный сон: как будто всё, что мы делаем, происходит в игре! Гав!»

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) и пишет интересный диалог, оставаясь в образе и релевантно контексту.

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

Такое разделение обязанностей сработало идеально.

Эмерджентное поведение

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

Mitzi mentions European leaders meeting Trump and Zelenskyy
Митци: «Что в новостях? Европейские лидеры планируют встретиться с Трампом и Зеленским!»

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

Cookie says Tom Nook is taking all the bells
Куки: «В городе всё замечательно, но иногда мне кажется, что Том Нук получает все лавры!»

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

Cookie says a woman was killed in a robbery in a blue city
Куки: «При ограблении в синем городе убили женщину!»

Теперь игра стала более странной, забавной и немного тревожной.

Весь код проекта, в том числе интерфейс памяти, кодировщик диалогов и логика промптинга ИИ выложена на GitHub. Это был один из самых сложных и интересных моих проектов, объединивший в себе реверс-инжиниринг, ИИ и сильную любовь к классической игре.

Полное видео можно посмотреть здесь: Modern AI in a 24-Year-Old Game

Комментарии (1)


  1. Rive
    11.09.2025 20:12

    Когда я увидел на ютубе, как игрок таскает за собой начальника на разборки в канцелярию Сейда Нин или играет в поле чудес в подвале гильдии магов Балморы, я осознал что живу в будущем.

    Хотя, конечно, игровые LLM пока ещё сильно глючат.