В свободное время я играю в World of Warcraft — в игру, которую я нежно люблю второй десяток лет. С тех пор как в игре появились кросс‑серверные и межрегиональные группы, я зачастую читаю чат и не понимаю, что говорят те же немцы или французы.
Собираешься в пуг — случайную группу из незнакомых людей, которую подбирает автоматический поиск. Танк объясняет тактику по‑испански, хилер о чём‑то спрашивает по‑немецки, ты пишешь по‑русски. Идёт пулл, все ложатся, и кто‑то пишет «gg noob» — единственную фразу, которую тут знают все.
Полгода назад я взялся сделать переводчик чата и с тех пор не написал ни строки кода руками. 158 моих коммитов, 14 тысяч строк на Python, 8 тысяч на Lua, 1200 на Rust, релизы на CurseForge и Wago. Всё это писал Клод.
Проект при этом не только мой, и вот как это вышло. После первой версии я написал о ней на Reddit — и там меня закидали камнями за ИИ. Популярности я не ждал вообще никакой. А потом увидел pull request от незнакомого человека, который сделал поддержку Linux и добавил ещё один переводчик.
Так в проекте появились 52 коммита из 195, которые писал не я. Говорю об этом сразу, потому что дальше речь пойдёт в том числе про Rust‑сканер, и путать вклад было бы нечестно. История с якорем, о которой вся статья, целиком из моих коммитов.
Я ставил задачу, требовал объяснений, гонял варианты и говорил «не то». В его предложениях разбирался сам, и там, где он начинал галлюцинировать, решение предлагал тоже сам.
Работа шла через мой фреймворк TAUSIK, построенный по методологии SENAR. Про сам подход я уже писал на Хабре серию из пяти статей, поэтому здесь повторяться не буду: фреймворк появится в тексте ровно там, где без него непонятно, откуда берутся даты и номера задач.
Дальше — про то, как приложение рядом с игрой читает чат прямо из памяти процесса. И почему первый способ это делать сжигал половину ядра.

Внизу сообщение уходит в чат игры, вверху оно же появляется переведённым. Между этими двумя событиями — всё, о чём статья.
Что уже есть на этой поляне
Первым делом покрутил анализ с помощью клода с разных сторон: чем люди пользуются сейчас и почему это не закрывает вопрос.
Есть аддон WoW Translator от Pirson: словарь игровых терминов на 14 языках, подсказка прямо в строке чата. Хорошая штука, и я взял её за основу — написал автору, получил одобрение на форк базы, перешёл на MIT ради совместимости. 314 терминов из наших 436 пришли оттуда, и идея глоссить чат прямо в игре тоже.
Есть Prat — большой комбайн для чата, у него внутри свой буфер истории. Его я разобрал и отверг, и вот почему: historyBuffer — это его собственный кольцевой буфер на Lua, 120 записей, и он стирается на /reload; автор, GUID и канал доступны чище прямо в событии CHAT_MSG_; а RawHook, которым Prat перехватывает чат, — это ровно тот taint, от которого мы уходим.
Есть, наконец, WoWChatLog.txt — игра умеет писать чат в файл. Пробовал. WoW копит лог во внутреннем буфере около 4 КБ и сбрасывает его когда придётся, так что открываешь файл посреди боя, а журнал прекращается буквально на полуслове: сообщение доходит через минуту, а то и через пять. Для перевода тактики в бою это не работает вообще никак.
Весь этот разбор Клод сделал за один день, 16 марта, и он записан. Отказ от Prat лежит в журнале решений проекта с обоснованием, а не в переписке, откуда его через месяц никто не достанет.
Почему вообще понадобился второй процесс
Вот здесь всё и упирается.
Аддон в WoW живёт в песочнице Lua, и эта песочница не умеет ходить в сеть. Совсем. Аддон видит каждое сообщение чата, рисует интерфейс, хранит настройки — и не может спросить переводчик. Игра не позволяет манипуляцию с внешними связями — замкнутый контур игры.
Значит нужен второй процесс рядом с игрой, который в сеть ходить умеет. Значит, надо как‑то передать ему чат.
Чат он ловит так: подписывается фильтром на события и получает каждое сообщение до того, как игра его нарисует. Список событий и подписка на них лежат в Core.lua в полутора сотнях строк друг от друга, здесь я их свёл:
local CHAT_EVENTS = { "CHAT_MSG_SAY", "CHAT_MSG_YELL", "CHAT_MSG_WHISPER", "CHAT_MSG_WHISPER_INFORM", "CHAT_MSG_BN_WHISPER", "CHAT_MSG_PARTY", "CHAT_MSG_PARTY_LEADER", "CHAT_MSG_RAID", "CHAT_MSG_RAID_LEADER", "CHAT_MSG_RAID_WARNING", "CHAT_MSG_INSTANCE_CHAT", "CHAT_MSG_INSTANCE_CHAT_LEADER", "CHAT_MSG_GUILD", "CHAT_MSG_OFFICER", "CHAT_MSG_CHANNEL", "CHAT_MSG_EMOTE", "CHAT_MSG_BATTLEGROUND", "CHAT_MSG_BATTLEGROUND_LEADER", } -- ... и ниже по файлу, при загрузке аддона: for _, e in ipairs(CHAT_EVENTS) do ChatFrame_AddMessageEventFilter(e, ChatFilter) end
18 событий, весь чат игры. А дальше — тупик: ни одной функции, которая отправила бы это наружу, в песочнице нет.
Целиком дорога сообщения от игры до оверлея выглядит так:

Дорога сообщения от игры до оверлея целиком. Единственная связь между двумя процессами — стрелка посередине, и она односторонняя.
Файл отпадает, я показал выше. Остаётся память процесса.
Не вдаваясь в суровые дебри кода — я сходу вижу несколько проблем. В тот же день, 16 марта, пять подходов уехали в реестр тупиков проекта закрытыми:
чтение буфера через
FILE*из стандартной библиотеки C — WoW не пользуется буферизованным вводом‑выводом libc;принудительный сброс лога переключением
LoggingChat, это функция Lua‑API клиента, — она не закрывает файловый дескриптор;смещения кольцевого буфера чата — неверны для клиента 12.0 и выше;
хуки на
AddMessage, метод фрейма чата в том же Lua‑API, — дают taint, то есть помечают код аддона как недоверенный и запирают ему часть функций;HTTP и файловый ввод‑вывод из аддона — запрещены песочницей Lua, в которой аддон и живёт.
Останься это пятью строчками в переписке, через месяц их никто бы не нашёл. А так это запись, которая подгружается в начале каждой следующей сессии, и за следующие 5 месяцев ни один из них не всплыл повторно. Дальше будет видно, зачем это оказалось нужно.
На чём это написано и почему
Раз уж речь пойдёт про память процесса, сразу отвечу на вопрос, который на Хабре задают первым: почему Python с Rust, а не C++, Go или ассемблер.
Сразу оговорюсь: клоду в общем всё равно, на чём писать. Язык тут вообще не главное — главное, кто ставит задачу, кто проверяет результат и кто понимает, что происходит. Мастерство инженера первично, язык вторичен. Но выбор всё‑таки был осознанный, и вот из чего он складывался.
Аддон — Lua, и тут выбирать не из чего: в WoW других языков для аддонов нет, версия 5.1, и она же встроена в клиент.
Компаньон — Python, и причина не в производительности. Агент пишет хорошо на том, на чём хорошо обучен, и хромает там, где данных обучения было мало. Python из подходящих языков самый населённый: под всё нужное библиотеки уже написаны — PyQt6 и GTK4 под оверлей, lingua‑py под офлайновое определение языка, requests под провайдеров перевода. Ставка была на то, что здесь агент проедет дальше и с меньшим числом моих вмешательств. Так и вышло.
Rust появился ровно в одном месте и по замеру, а не по вкусу. Чистый Python‑сканер, читавший /proc/<pid>/mem, не успевал: перебор регионов памяти на интерпретаторе слишком дорог. Библиотека на Rust заменила его и дала самое крупное ускорение с момента появления поддержки Linux. Rust я взял, а не C++, потому что при работе с чужой памятью границы буферов и владение дескриптором дешевле держать в языке, который следит за этим сам, — а готовые крейты закрывали и системные вызовы, и параллельный обход регионов.
Go не рассматривал. Чтение чужой памяти — тонкий слой над двумя системными вызовами, рантайм со сборщиком мусора тут ничего не даёт, а к Python‑процессу его пришлось бы прикручивать через cgo.
Ассемблер не нужен вовсе. Я ничего не внедряю и не перехватываю, только читаю. Вся тяжёлая работа — это ReadProcessMemory на Windows, process_vm_readv на Linux и сравнение байтов; упирается она в пропускную способность чтения, а не в число инструкций.
Как читался чат
Схема, которая работала с марта, простая.
Внутри игры аддон складывает сообщения в кольцевой буфер на 50 сообщений и раз в четверть секунды собирает из него одну строку. Строку он обрамляет литеральными маркерами: __WCT_BUF_0042__ в начале, __WCT_END__ в конце, номер в заголовке — для проверки на свежесть.
Компаньон ищет эти маркеры в памяти игры. Параллельно обходит читаемые регионы процесса, читает их кусками, сравнивает байты.
Одна деталь оттуда доживёт до конца статьи, поэтому назову её сейчас: в каждом регионе берётся лучшая копия, а не первая найденная. В коде про это сказано так — «returning the first is what let a dead copy win purely by living at a lower address». Первая попавшаяся выигрывала просто потому, что лежала по младшему адресу. К этой фразе я ещё вернусь, ближе к концу.

Обшарить всё разом и не найти. Живая руна горит там, куда не дотянулся ни один луч.
Схема работала, и работала долго, и никто её особо не трогал до того дня, когда я сел посмотреть, во что она обходится. 48,4% одного ядра, 5 сообщений в минуту, после чего сканер молча глохнет и не приходит в себя до перезапуска. И это на пустом, в общем‑то, чате.
Чинить это я взялся не в августе. Первый заход был ещё в марте, и разбирать его придётся подробно — без него всё, что было дальше, выглядит везением.
Первый заход, март
Дальше по тексту пойдут карточки задач — с датами, целями и критериями приёмки. Чтобы они не читались декорацией, скажу коротко, откуда они берутся.
Где‑то год назад, когда я уже достаточно в какой‑то мере освоился в агентской практике для рутины, я начал писать TAUSIK — тот самый, на который ссылался в начале. Правил в нём много, для статьи хватит четырёх:
нет активной задачи — правки кода заблокированы, физически;
задачу нельзя открыть без критериев приёмки, среди которых обязателен отрицательный случай, потому что выполнение задачи от забора до обеда нам не подходит;
задачу нельзя закрыть без доказательства под каждым критерием;
решения, тупики и соглашения живут в базе проекта и подгружаются в начале каждой сессии.
Всё. Больше про фреймворк рассказывать не буду — дальше он виден сам, по датам и номерам задач.
Итак, 19 марта. Задача fix-zombie-buffers, сложность complex, роль архитектора. Входит в историю с говорящим названием: «Memory Reader v3: Zombie‑Free Architecture».
Цель звучит так: модуль чтения памяти всегда находит живой буфер аддона за 500 мс и никогда не залипает на адресах, которые уже подобрал сборщик мусора. Полный проход по куче — реже чем в 5% циклов.
План из шести шагов:
исследовать, как сборщик мусора Lua переносит
TString, внутреннее представление строки, в памяти WoW;ворота свежести: если кэшированный адрес 3 опроса подряд отдаёт тот же номер, признать его протухшим;
прицельное пересканирование последних 500 МБ прежде полной кучи;
чёрный список зомби‑адресов с временем жизни 60 секунд, потому что сборщик переиспользует адреса;
замер на 100 подряд опросах.
Критерии приёмки под стать: живой буфер за полсекунды в 95% опросов, зомби распознан за 3 цикла.
Всё это — хорошая инженерия. Каждый шаг разумен, каждый решает настоящую проблему, и если бы меня спросили тогда, я бы согласился со всеми шестью. Остальные решения исходят из здравой логики и анализа проблемы — и этот план ровно из них. А весь список целиком лежит внутри одного невысказанного допущения: адрес надо найти, а потом проверять, не протух ли он.
История называлась «Zombie‑Free Architecture». Зомби остались.
Я это к чему. Искать нужный подход 5 месяцев — нормально, так бывает. Ненормально — искать его 5 месяцев по кругу, каждую сессию заново предлагая то, что уже не сработало. Что из двух получилось, видно по тому, что осталось записано между сессиями.
Midnight, секретные значения и живой тест
В августе ко мне обратился соотечественник и попросил добавить переводчик, который работает из России без VPN. Заодно вышел второй сезон Midnight, и я решил освежить аддон целиком.
Как было раньше. Аддон ловил чат опросом GetMessageInfo — это метод фрейма чата в Lua‑API клиента — по всем 10 окнам каждые 200 мс. Внутри подземелий строки приходили порчеными, и лечилось это pcall‑обёрткой вокруг конкатенации: обернул, не упало, поехали дальше. TOC объявлял совместимость с 12.0.1, буфер собирался раз в 1,5 с.
А теперь смотрите, что лежало в реестре проекта с 16 марта, за пять месяцев до того, как понадобилось:
В Midnight ограничения могут расшириться — надо проверить, какие каналы затронуты и остаётся ли
GetMessageInfoдоступным на чтение.WoWChatLog.txtсекретные значения не затрагивают, запасной путь остаётся рабочим.
Записка самому себе на пять месяцев вперёд. Проверить вот это и вот это.
Ограничения расширились. Часть событий, наоборот, рассекретили — CHAT_MSG_LOOT, MONEY, CURRENCY, весь COMBAT_*, — но я ни на одно из них не подписан, мой список это только чат игры. А главное ограничение осталось на месте. Пока идёт эпохальный ключ — и только пока он идёт — игра отдаёт аддонам текст чата секретным значением. Это ровно то место, где перевод тактики нужнее всего, и поделать с этим нельзя ничего.
Что такое секретное значение с точки зрения аддона. Это объект, который сообщает о себе как о строке и бросает исключение почти на всём. Конкатенировать его можно — разрешены склейка и string.concat, format, join. А сравнить, проверить на истинность или спросить длину — нельзя, любая из этих операций роняет.
Отсюда стало понятно, почему обёртки вокруг конкатенации перестало хватать. Значение успевало попасть в сравнение внутри дедупликации и лечь в буфер раньше, чем его кто‑нибудь проверял. Обёртка стояла не в том месте: она защищала последний шаг, а падало на первом.
Пробу перенесли на вход. Теперь аддон проверяет каждое значение из события до того, как сравнит его, сохранит или положит в кольцевой буфер.
Проверяем так: pcall(string.len, value), где pcall — это защищённый вызов в Lua, возвращающий признак успеха вместо того, чтобы уронить аддон. Это самая дешёвая операция ровно того класса, который выполняется дальше: прошла она — пройдут и остальные.
И одной её мало. Lua 5.1 коэрсит числа, string.len(42) спокойно возвращает двойку, — то есть проба длины пропустила бы число, а вызывающий потом обратился бы к нему как к строке и всё равно упал бы. Поэтому рядом стоит pcall(type, value).
Вот проверка целиком, вместе с комментарием, который всё это и объясняет:
local function IsUsable(value) -- Two separate questions, and only asking one of them is a trap. -- -- `type` first: string.len(42) SUCCEEDS in Lua 5.1 — numbers coerce — so a -- length probe alone waves a number through, and the caller then indexes it -- as a string and raises. `type` is itself wrapped, because a secret value -- is not something we are entitled to inspect either. local ok, kind = pcall(type, value) if not ok or kind ~= "string" then return false end -- Then the secret probe. A secret string reports as a string but rejects -- being measured, which is exactly the operation the buffer performs next. return (pcall(string_len, value)) end
Отдельно — что видит игрок. Аддон запоминает отказ на 30 секунд и кладёт в буфер поле LOCKED, чтобы оверлей сказал, почему замолчал. Само ограничение снять нельзя, это Blizzard. А вот то, что оверлей просто останавливался и не объяснялся, было нашей виной.
А теперь про то, зачем этот раздел стоит в статье про память.
Задача на адаптацию называлась «Бамп аддона под Midnight плюс живая проверка capture и памяти», и живая проверка памяти стояла прямо в критериях приёмки: компаньон читает буфер из процесса игры и находит в нём маркер. То, что эта проверка вскрыла, поехало в отдельную историю с названием «Дефекты компаньона, найденные на живом тесте 12.1.0».
Переделывать транспорт я не садился. Я обновлял аддон под новый сезон и обязан был проверить память живьём — потому что так был написан критерий приёмки.
Замер, который закрыл вопрос
Раздел будет короткий, но он тут главный.
Буфер — это строка Lua. Строки в Lua неизменяемы: каждое перестроение выделяет новую строку в новом месте, а старая остаётся лежать, пока до неё не дойдёт сборщик.
Из этого следует ровно то, чем я полгода объяснял устройство и себе, и другим: приходится искать заново. Такие вещи требуют умения анализа, осмысления проблемы в комплексе, разбирания соприкасающихся участков, а я обходился присказкой на одну строчку.
В августе в процессе копания оказалось, что за этой присказкой стоит число.
14 перестроений подряд легли в 14 разных регионов памяти. Разброс — 20 ГБ. Ни одного возврата туда, где строка уже была.
Вот тут допущение и кончилось.
Пока это фраза «адрес меняется, приходится искать заново», её можно чинить: искать умнее, кэшировать адрес, вести чёрный список, сканировать последние 500 МБ вместо всей кучи. Ровно это и делала мартовская задача.
Когда это число, чинить нечего. Полный проход по куче за каждое перестроение — и перестроение происходит 4 раза в секунду. Искать то, что убегает, пока ты ищешь, бессмысленно по самому устройству задачи. Тут дело уже не в качестве реализации.
Замер сделал не я. Я спросил у клода, как вообще получается, что компаньон залипает на мёртвой копии, — и вместо рассуждения он принёс прогон с 14 адресами.
Допущение убил замер. Спорить о подходах можно было ещё полгода.
Якорь
Идея, которую я услышал в ответ, была неприятно простой.
Строку ловить не надо вообще. Надо положить рядом с ней что‑нибудь, что никуда не денется, и спрашивать дорогу у него.

Не гнаться за тем, что убегает. Поставить неподвижное и спросить дорогу у него.
В сохраняемой таблице аддона появляется число: 8675309123457. Оно не меняется никогда, и искать его можно сколько угодно долго — оно не убежит, пока ищешь. В этом вся разница с буфером.
А рядом с ним, в той же таблице, лежит ячейка с указателем на текущую строку. Нашёл число один раз — дальше читаешь 8 байт и получаешь адрес буфера, куда бы тот ни переехал. Поиска больше нет вовсе.
Про само число стоит сказать отдельно, потому что требований к нему больше, чем кажется.
Оно обязано быть именно числом. Строка в Lua — это объект в куче, её переселит тот же сборщик, и якорь уехал бы вслед за буфером. Число живёт прямо в ячейке таблицы.
Оно обязано точно ложиться в double: в Lua 5.1 других чисел нет вовсе, целых типов там не существует. Целое представимо точно, пока оно меньше 2^53, и наше на порядки меньше.
И оно обязано не встретиться в памяти случайно. Иначе сканер найдёт 300 якорей вместо одного и будет перебирать их до вечера.
Отсюда и вид. 8675309 — это телефон Дженни из песни Tommy Tutone 1981 года, старая шутка, которую программисты суют в тестовые данные лет сорок. Хвост 123457 приписан, чтобы совпадение стало совсем невероятным.
Причём проверено это было до того, как написали хоть строку кода. Файл anchor.rs с этого и начинается:
Proven on a live game before any of it was written: one slot, six reads over twelve seconds, six different string addresses, every one a valid buffer.
Одна ячейка, 6 чтений за 12 секунд, 6 разных адресов строки — и каждый оказался живым буфером. Догадку сначала проверили на работающей игре и только потом сели её писать.
Дальше начинается возня с байтами, и она короткая.
А чтение на стороне сканера после всего этого занимает 17 строк:
pub(crate) fn read_via_slot(handle: HANDLE, slot: usize, skip: usize) -> Option<Vec<u8>> { let mut pointer_bytes = [0u8; 8]; if !read_memory(handle, slot, &mut pointer_bytes) { return None; } let pointer = u64::from_le_bytes(pointer_bytes) as usize; if pointer < 0x10000 { return None; } let mut raw = vec![0u8; MAX_BUF_READ]; if !read_memory(handle, pointer + skip, &mut raw) { return None; } let cs = find_content_start(&raw)?; let ep = raw[cs..].windows(MARKER_END.len()).position(|w| w == MARKER_END)?; Some(raw[cs..cs + ep].to_vec()) }
Никакого поиска. Прочитал 8 байт, сходил по адресу, отдал содержимое.
Ячейку ищут в окне 8 КБ по обе стороны от числа; на проверочном прогоне она нашлась в 112 байтах ниже. По найденному указателю лежит заголовок строки Lua, а текст начинается за ним, поэтому сканер перебирает смещения из списка 32, 24, 16, 40, 48, 0 — верным оказывается первое, за которым обнаружились маркеры.
Остаётся вопрос, на котором всё это держится: почему ячейка стоит на месте, если буфер не стоит?
Потому что хранилище таблицы Lua не двигается, пока таблицу не перестраивают, а перестраивает её добавление ключей. Значит, добавлять нельзя — и аддон объявляет все ключи разом при загрузке. В том числе три пустышки r1, r2 и _r3, которые не делают ровно ничего.
Выглядит это так — и здесь же видно те самые пустышки:
function addonTable.PreallocateCompanionKeys() local db = BabelChatDB if db.wctbuf == nil then db.wctbuf = "" end if db.wctSeq == nil then db.wctSeq = 0 end if db.wctFlush == nil then db.wctFlush = 0 end -- A number that never changes, so the companion can find it however long -- the search takes, and know where this table's storage lives. Everything -- else here moves or ticks: the buffer string is reallocated on every -- rebuild, and a search for a value that changes while you search finds -- nothing. This one is the fixed point the rest is measured from. db.wctAnchor = 8675309123457 if db._r1 == nil then db._r1 = 0 end if db._r2 == nil then db._r2 = 0 end if db._r3 == nil then db._r3 = 0 end -- Restore seq counter so it survives /reload (reader tracks by seq) wctSeq = db.wctSeq or 0 -- Carried across a reload for the same reason as the sequence: the reader -- compares pulses between copies, and a pulse that restarted at zero would -- make the live buffer look older than the corpse of the previous session. wctFlush = db.wctFlush or 0 end
Три бесполезных поля, вся работа которых — чтобы соседнее поле не переехало. Костыль, конечно. Но опирается он на устройство самого языка, а никак не на удачу.

Одна и та же задача, решённая двумя способами. Разница между панелями — в 484 раза.
Как отличить тишину от смерти
Дальше самая интересная часть. Она про одну ошибку, которую я нашёл дважды — на двух разных уровнях, с разницей в несколько часов.
Сначала нижний.

Слева живой буфер, справа копия, в которую уже никто никогда не напишет. С одного взгляда не различить.
Сборщик мусора переносит буфер на новое место. Старые байты остаются лежать там, где лежали, и продолжают разбираться как буфер: те же маркеры, тот же номер последнего сообщения. Сканер помнит адрес, читает по нему и видит осмысленную запись. Ничего нового в ней не появляется — ну так, может, никто и не пишет.
Отличить эту картинку от тихого чата ему нечем. И он не пересканировал ни разу.
Насколько это плохо, видно по замеру на живом прогоне. 4 сообщения гильдии, отправленные с 15:24:45 по 15:25:05, дошли до приложения одним пакетом в 15:29:06. Всё это время оно исправно читало копию, в которую уже никто никогда не напишет.
Теперь этажом выше, и там ровно то же самое.
Игрок делает /reload. Старая таблица остаётся в памяти, пока до неё не доберётся сборщик, и какое‑то время в процессе живут две таблицы с одинаковым числом внутри. Мёртвая отвечает на любой вопрос теми же словами, что и живая.
В коде про это сказано без всякой пощады к себе:
It cost a hundred and eighty seconds of silence to learn that twice.
180 секунд тишины, чтобы выучить это дважды. Дважды — потому что первый раз, этажом ниже, не был записан как урок.
Лечит и то и другое одна вещь. Пульс.

Счётчик, который тикает независимо от того, говорит кто‑нибудь или нет. У живой копии идёт, у мёртвой стоит.
Аддон кладёт в буфер счётчик, который растёт при каждом перестроении, сказал кто‑нибудь что‑нибудь или нет. Раз в 2 секунды буфер пересобирается вхолостую только ради того, чтобы счётчик тикнул. И мёртвая копия теперь видна с одного взгляда: у живой счётчик идёт, у мёртвой стоит.
Порог — 6 секунд, три удара. В комментарии объяснено, почему именно столько: этого хватает, чтобы не сомневаться, и всё равно в 8 раз быстрее, чем заметить смерть по сломавшемуся указателю.
А дальше три детали, и каждая стоила отдельного бага.
Первая. Счётчик переносится через /reload. Начнись он с нуля — и живой буфер после перезагрузки выглядел бы моложе того, что остался от прошлой сессии, а сканер выбрал бы старший.
Вторая. Считается он до сборки строки, а записывается после. Поменяй местами — и при сбое сборки число в памяти уйдёт вперёд числа на диске, /reload вернёт меньшее, счётчик шагнёт назад. Шаг назад сканер понимает как смерть. В коде порядок закреплён комментарием, чтобы следующий не переставил:
-- Computed here, committed after the concat succeeds. Advancing the -- counter first and then failing would leave the number in memory ahead of -- the number on disk, so a /reload would restore the smaller one and the -- pulse would step BACKWARDS — which the reader takes as a sign that the -- buffer it is holding has died. local pulse = wctFlush + 1 -- ... BabelChatDB.wctbuf = table.concat(parts, "\n") wctFlush = pulse BabelChatDB.wctSeq = wctSeq BabelChatDB.wctFlush = wctFlush
Третья. Кандидатов на якорь бывает два, и сканер берёт того, у кого пульс живее. Та же самая ошибка, что этажом ниже, где побеждала первая попавшаяся копия — просто потому, что лежала по младшему адресу.
Опознание таблицы, оставшейся после /reload, занимает теперь 6 секунд вместо 3 минут.
Чат пишут другие люди
Осталось рассказать про место, где вся конструкция ломается об игрока с фантазией.
Буфер несёт литеральные маркеры, и до одного из них дотягивается кто угодно. Игрок пишет в торговый чат __WCT_END__. Аддон честно кладёт это в буфер, компаньон честно считает, что буфер на этом закончился, и молча теряет всё, что было после.
Чиню я это заменой, а не экранированием, и это осознанный размен. Перевод строки и табуляция в чате смысла не несут, а имя канала не может содержать вертикальной черты — восстанавливать после замены нечего. Экранирование стоило бы алфавита экранирования, версии протокола и неоднозначности против буферов от аддонов старых версий.
А дальше два ограничения на сам заменитель, оба выученные на живом баге.
Сама замена — три строки, и вся соль в константе над ними:
local _MARKER_STAND_IN = "(WCT)" local function SanitizeText(value) local out = string_gsub(value, "[\n\r\t]", " ") out = string_gsub(out, "__WCT_", _MARKER_STAND_IN) return out end
Первое: в заменителе не должно быть подчёркивания. gsub, замена по образцу в Lua, продолжает сканирование после совпадения и не возвращается на шов, который только что написал. Строка WCTWCT_END__ при замене превращалась в WCT плюс WCTEND__ — то есть в живой конечный маркер, собранный из собственного вывода замены.
Второе: заменитель не должен начинаться с [WCT]. Этот префикс у компаньона в списке служебной болтовни аддонов, которую он игнорирует, — и покалеченное сообщение вместо того, чтобы дойти покалеченным, молча выбрасывалось уже на приёме.
И последнее из этой оперы. Вся сборка буфера обёрнута в тот же pcall. Без него одна запись, которую не удалось сериализовать, давала бы ошибку Lua каждую четверть секунды до перезагрузки игры, а флаг «есть что отправить» не сбрасывался бы никогда — то есть компаньон не получил бы больше ни одного сообщения до конца сессии.
Сухие цифры
было | стало | |
|---|---|---|
загрузка ядра | 48,4% | 0,10% |
полных проходов по куче | на каждое перестроение буфера | 0 |
поиск якоря | - | 662 мс, один раз за подключение |
чтение на опрос | обход регионов памяти | 8 байт |
доставка | 5 сообщений в минуту, потом тишина | в том же опросе, в котором отправлено |
распознание таблицы после | 3 минуты | 6 секунд |
Оба процента сняты на одном и том же чате одной и той же сборкой сканера.
Проект целиком:
компаньон | 13 946 строк Python |
аддон | 7 979 строк Lua в 19 файлах, без вендоренных библиотек |
сканер | 1 209 строк Rust |
тесты | 1 091 штука на 10 934 строках |
коммиты | 195 с 15 февраля по 24 августа, из них 158 моих |
языки перевода | 22 |
Аддон тестируется настоящим интерпретатором Lua 5.1 через lupa — той же версией, на которой работает игра. Не переписыванием логики на Python, который потом разойдётся с оригиналом и будет давать ложную уверенность.
Почему второй заход не повторил первый
Остался один вопрос, ради которого я вообще завёл разговор про фреймворк.
В марте была задача про зомби‑буферы. В августе — задача про пульс. Обе про один и тот же дефект, между ними 5 месяцев. Почему вторая не оказалась третьим кругом первой?
Разница видна в одной строке. В формулировке цели.
Март: модуль чтения памяти находит живой буфер за 500 мс, полный проход реже 5% циклов.
Август: читатель отличает живой буфер от копии, оставшейся после сборки мусора, за один опрос, а не по косвенным признакам.
Разница вот в чём. В мартовской цели стоит слово «находит». То есть решение искать было принято до того, как задачу открыли, и вся работа свелась к тому, чтобы искать лучше: кэш адреса, чёрный список, прицельный проход по последним 500 МБ.
В августовской про поиск нет ни слова. Там сказано только, что компаньон обязан отличать живой буфер от мёртвого за один опрос. Каким способом — вопрос открытый.
И как только он стал открытым, выяснилось, что искать не нужно вообще.
Вот она целиком, как лежит в базе проекта:

Та самая карточка. Цель описывает поведение, а не правку, и один критерий обязан быть отрицательным.
Дальше в августовской карточке 6 критериев приёмки, и один обязан быть отрицательным — описывать случай, в котором решение окажется неверным. Здесь это старый аддон: копию без пульса, пришедшую от него, компаньон всё ещё читает и не выбрасывает. Новая схема не имеет права сломать тех, кто не обновился.
Потом я попросил сломать собственное решение. По одному месту за раз, и смотреть, заметят ли тесты.
Аддон перестал класть якорь. Константы в аддоне и сканере разошлись. Якорь вынесли из преаллокации ключей. Побеждает первый кандидат вместо самого живого. Замороженная ячейка держится вечно. Убран холостой пульс. Вернулось выравнивание.
7 способов сломать — 7 пойманных.
Закрыть задачу с первого захода не вышло: ворота не пустили, filesize=FAIL. Файл привели в порядок, прогнали снова, filesize=PASS.
А доказательством закрытия пошёл прогон в живой игре. Сообщения с 16 222 по 16 225 пришли в 17:01:21.95, 17:01:22.95, 17:01:25.70 и 17:01:28.21 — в том же опросе, в котором отправлены. Гильдейский чат шёл непрерывно 10 минут.
И три вещи, без которых всё это выглядело бы рекламой.
Реестр протухает. Запись про модуль чтения памяти до сих пор требует прав администратора, хотя их сняли 21 августа. Реестр помнит только то, что в него положили.
Рельсы появились не с первого дня. Проект начат 15 февраля, первая сессия под фреймворком — 16 марта.
Оценка улетела в 12 раз: у задачи на адаптацию под Midnight бюджет был 25 вызовов инструментов, ушло 307. А ещё в проекте есть история с названием «Vibe‑coding cleanup» под эпиком «Code Quality Refactoring». Убирать за собой всё равно приходится.
Где это не работает
Раз уж я хвалю решение, назову и то, чего оно не умеет.
Якорь работает до тех пор, пока аддон и сканер согласны про одну константу. Разъехались версии — константа не совпала, ячейка не нашлась, и всё возвращается к полному проходу по куче со всей его прежней ценой. Поэтому полный проход из кода никуда не делся: он остался запасным путём для аддонов, у которых якоря ещё нет, и для того, чтобы найти ячейку в первый раз.
Дальше — вещь посерьёзнее. Схема опирается на внутреннее устройство таблиц Lua: хранилище стоит на месте, пока таблица не перестроится. Blizzard такого контракта не давал. Это наблюдаемое поведение конкретной реализации, и очередное обновление клиента может его сломать в любой момент, а чинить придётся с нуля.
Секретные значения снять нельзя вообще никак. Внутри эпохального ключа перевод чата не работает и работать не будет, что бы я ни делал. Всё, что я могу, — сказать игроку, почему оверлей замолчал.
Задержка перевода — от 0,5 до 2 секунд. Это дорога до серверов провайдера и обратно, и она такая же у любого облачного переводчика.
И про сам метод. Полгода без ручных правок кода не означают, что я в этом не участвовал. Я не писал код, но формулировал задачу, спрашивал «почему так», отвергал варианты и требовал замер вместо рассуждения. Уберите эту часть — и останется генератор правдоподобного кода, которому некому сказать «не то».
Про бан, и про права администратора заодно
Когда я выложил аддон на Reddit, обсуждали там не архитектуру. Обсуждали, не забанят ли за такое.
Возражение пришло с цитатой из пользовательского соглашения — про любой неавторизованный процесс, который перехватывает, собирает или читает информацию, порождённую платформой:
Use with care, this tool might be against the tos! Reading the memory of the wow process is potentially a really bad idea. Especially since it circumvents the addon chat restrictions in dungeons/raids.
А следом пришёл комментарий, который попал точно в механику и собрал больше всех голосов:
It reads a Lua string that the addon writes to a SavedVariable. Before it is written to disk, which circumvents the mechanism blizzard implemented to restrict lua addon to external tool communication.
Это правда, и спорить тут не с чем. Blizzard не даёт аддону выходить наружу, а компаньон читает его сохраняемую переменную из памяти до того, как игра запишет её на диск. Ровно то, о чём человек и написал.
Что я отвечал тогда и отвечаю сейчас.
Память только читается: ничего не пишу, ничего не внедряю, за игрока ничего не делаю. Аддон работает и без компаньона — остаётся словарь и подсказка прямо в строке чата. Серая зона названа серой зоной внутри проекта ещё 24 марта, до релиза, а не после того, как спросили. И если Blizzard скажет, что так нельзя, — значит нельзя, и компаньон уедет в мусор вместе с якорем и пульсом.
Отдельно про права администратора, потому что урок тут встречный. Сборка просила их полгода — на всякий случай, так было проще. А потом выяснилось, что ReadProcessMemory к своему же процессу их не требует, зато постоянно повышенные права превращают любую подмену DLL рядом с исполняемым файлом в повышение привилегий.
То есть требование администратора делало продукт не безопаснее, а опаснее.
Что осталось в сухом остатке
Отойду на шаг. Штука вышла нахальная.
WoW не пускает аддоны в сеть, и правильно делает: иначе каждый аддон стал бы каналом наружу. Запрет этот честно не обходится. А чат при этом переводится в реальном времени — на 22 языка, с задержкой в один опрос, при 0,10% ядра, и в памяти игры не меняется ни байта. Аддон пишет в собственную сохраняемую переменную, компаньон читает её снаружи по указателю, который аддон сам ему и оставил.
И написал это всё агент, которому я не дал ни строчки кода.
При этом полгода решение было плохим, а выглядело нормальным. Чат ехал, оверлей работал, 48% ядра списывались на то, что сканирование памяти дорогое, и жаловаться было некому.
Поменял всё один замер. Пока «адрес меняется» оставалось фразой, его чинили пять месяцев, и каждая починка выглядела прогрессом. Когда оно стало числом — 14 перестроений, 14 регионов, 20 ГБ, ни одного возврата, — чинить стало нечего.
И второе, без чего первого бы не случилось. У агента нет памяти между сессиями, её приходится делать снаружи. Пять подходов, закрытых в марте, не всплыли ни разу за 17 сессий, а мартовская записка про Midnight пригодилась в августе.
А всё остальное — обычное ремесло, и оно тут ровно такое же, как всегда.
Ссылки
BabelChat на GitHub — лицензия MIT, Windows и Linux. Релизы на CurseForge и Wago. Весь код, о котором шла речь, там: babelchat_scanner_win/src/anchor.rs и addon/BabelChat/CompanionBuffer.lua.
TAUSIK — фреймворк, через который всё это ехало. SENAR — методология, по которой он построен, и её репозиторий. Про сам подход я писал раньше серию из пяти статей.
Комментарии в коде я для статьи не переписывал. Они там ровно такие.

