Animal Crossing. Прославившаяся своей обаятельной, но в конечном счёте до боли повторяющейся болтовнёй. Вновь запустив этот классический хит для GameCube, я ужаснулся... жители по‑прежнему твердят те же самые фразы, что и двадцать три года назад. Ну уж нет, пора это менять.
Проблема? Игра работает на Nintendo GameCube — консоли 24-летней давности с процессором PowerPC на 485 МГц, 24 мегабайтами оперативки и полным отсутствием интернета. Она была создана — и технически, и концептуально — как замкнутый остров в офлайне.
И вот история о том, как я протянул кабель от 2001-го в сегодняшний день, заставив винтажную приставку разговаривать с облачным ИИ, не изменив ни строчки кода оригинальной игры.
Делегируйте рутинные задачи вместе с BotHub! По ссылке вы можете получить 100 000 бесплатных капсов и приступить к работе с нейросетями прямо сейчас.

Первая преграда: как заговорить с игрой ?️
Мой старт оказался удивительно удачным. В ту самую неделю, когда я взялся за проект, сообщество энтузиастов завершило гигантский труд — полную декомпиляцию 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 не смогла получить доступ к файловой системе хоста. Очередной тупик.
Прорыв: «почтовый ящик» в памяти ?
Решение пришло из классики моддинга: межпроцессное взаимодействие (IPC) через общую память. Смысл был прост — выделить кусок RAM GameCube в роли «почтового ящика». В этот ящик мой внешний Python‑скрипт может напрямую записывать данные, а игра — считывать их.

Сердце интерфейса «почтового ящика» (Python):
# Это мост. Функции читают и пишут данные в RAM GameCube через Dolphin.
GAMECUBE_MEMORY_BASE = 0x80000000
def read_from_game(gc_address: int, size: int) -> bytes:
"""Читает блок памяти по виртуальному адресу GameCube."""
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:
"""Записывает блок данных по виртуальному адресу GameCube."""
real_address = GAMECUBE_MEMORY_BASE + (gc_address - 0x80000000)
return dolphin_process.write(real_address, data)
Так я нащупал путь вперёд. Но вместе с ним появился новый, кропотливый квест: я превратился в археолога памяти. Нужно было найти точные и стабильные адреса для активного текста диалога и имени текущего собеседника.
Для этого я написал собственный сканер памяти на Python. Процесс превратился в однообразный ритуал:
Разговаривал с жителем. В тот миг, когда появлялось окно диалога, я замораживал эмулятор.
Сканировал. Скрипт прогонял все 24 миллиона байт RAM GameCube в поисках строки текста с экрана (например,
Hey, how's it going?
).Сверял. Часто находилось несколько адресов. Тогда я размораживал игру, заводил беседу с другим жителем и искал уже его имя, чтобы понять, какой блок памяти отвечает за активного собеседника.
После многих часов разговоров, заморозок и сканирования я наконец вычислил ключевые адреса: 0x8129A3EA
для имени говорящего и 0x81298360
для буфера диалога. Теперь я мог надёжно определять, кто именно говорит, и — что важнее — вписывать собственный текст прямо в окно диалога.
А что насчёт сетевого адаптера GameCube? ?
Да, у GameCube был официальный Broadband Adapter (BBA). Но Animal Crossing поставлялась без каких‑либо сетевых примитивов: ни сокетов, ни протоколов на уровне движка. Чтобы использовать BBA здесь, пришлось бы колхозить крошечный сетевой стек и патчить игру так, чтобы она его вызывала. Это значило: внедрять хуки в движок, планировать асинхронный ввод‑вывод, обрабатывать таймауты и повторы — всё внутри кода, который даже не подозревал о существовании сети.
Хуки в движке: перехватывать безопасные точки в цикле сообщений, чтобы отправлять и принимать пакеты.
Драйвер/протокол: реализовать минимальный интерфейс UDP/RPC поверх BBA.
Надёжность: справляться с таймаутами, повторами и неполными чтениями, не зависая при этом в анимации и UI.


Я выбрал «почтовый ящик» RAM, потому что он детерминированный, не требует ни ядра, ни драйверов и остаётся полностью внутри границ эмулятора — без какого‑либо бинарного сетевого стека. Хотя справедливости ради: схема с BBA абсолютно реальна. Более того, это была бы классная задача для будущего — например, для запуска на реальном железе через Swiss + homebrew.
Минимальная RPC‑обёртка для гипотетического BBA‑шима (C):
#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‑мост, упрощённая версия (Python):
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"))
# ... вызов Writer/Director LLM ...
reply = json.dumps({"ok": True, "text": " Hi from the cloud!"}).encode() // "Привет из облака!"
sock.sendto(reply, addr)
Разговор на тайном языке игры ?
С воодушевлением я попробовал записать в память Hello, world
— и… игра зависла. Персонажи продолжали анимированно шевелиться, но диалог не двигался дальше. Так близко — и так далеко.
Оказалось, что я отправлял обычный текст. А Animal Crossing на таком не говорит. У неё свой собственный язык — закодированный, с особыми управляющими символами.
Представьте себе HTML: браузер не просто показывает слова, а распознаёт теги вроде <b>
, чтобы сделать текст жирным. Так же и тут. Специальный байт‑префикс CHAR_CONTROL_CODE
сообщает движку игры: «Следующий байт — это не буква, а команда».
Эти команды управляют всем: цветом текста, паузами, звуковыми эффектами, эмоциями персонажей и даже завершением разговора. Если не передать код <End Conversation>
, игра будет вечно ждать команду, которая так и не придёт. Вот почему всё зависало.
И снова сообщество декомпиляторов выручило меня: они уже задокументировали большую часть этих кодов. Мне оставалось лишь собрать инструменты, чтобы ими пользоваться.
Я написал энкодер и декодер на Python. Декодер умел читать сырой участок памяти и переводить его в удобочитаемый вид, а энкодер — брать мой текст с самодельными тегами и превращать его обратно в точную последовательность байтов, понятных GameCube.
Небольшая часть управляющих кодов, которые пришлось кодировать/декодировать (Python):
# Пример набора управляющих кодов для Animal Crossing
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. Это, пожалуй, один из самых трудных и при этом самых захватывающих экспериментов, которые я когда‑либо делал: сплав реверс‑инжиниринга, ИИ и пристрастия к классике.
Artyomich
https://habr.com/ru/articles/946194/