Старые болячки украденного кода

Эхо забытых багов: Как мы научили Азерот говорить по-русски

23.02.2026

Призрак «слитых кодов»: почему локализация ломается одинаково во всех эмуляторах

Лирико-инженерная заметка о старой легенде, новых серверах и древнем баге, который путешествует между проектами как чемодан без ручки.
Есть у админов MMO-серверов особая музыка. Не та, что в рейде под вайпы, а другая — тихий скрип табличек в базе данных,
шелест дампов и тяжёлый вздох, когда очередной NPC, которого ты уже “точно перевёл”, снова улыбается тебе на английском.И вот тогда, где-то между третьей очисткой кеша клиента и четвёртым “да он же уже был русским!”, из тумана выплывает легенда.
Тот самый фольклор, который ходит по форумам с начала времён:
“Это всё потому, что когда-то давно у кого-то были слитые коды Blizzard”.

Легенда удобная. Она объясняет всё и сразу. Она романтичная. Она виновата вместо нас.
Проблема только в одном: чаще всего реальная причина — не тайная папка с кодом, а обычная человеческая ошибка индекса.

Но есть тонкая правда. Даже если отбросить сенсационную часть (и лучше её отбросить, потому что доказательств обычно нет),
остаётся другое: кодовые традиции путешествуют. Люди мигрируют между ядрами. Патчи кочуют.
Одинаковые “шаблоны решения” становятся одинаковыми “шаблонами проблем”.
И иногда один и тот же баг можно встретить в разных ветках так, будто он действительно бессмертен.

Как выглядит баг, когда он притворяется мистикой

Снаружи всё выглядит банально: локализация включена, ruRU настроен, таблицы заполнены.
Друзья заходят за Орду и Альянс, кликают NPC — половина текста русская, половина англоязычная.
На первый взгляд — “какая-то мистика”.

Внутри всё ещё проще: один и тот же NPC может говорить с игроком несколькими каналами.
И это ключ к тому, почему “мы перевели, а он не перевёлся”.

В мире серверных эмуляторов нет “одного текста NPC”. Есть несколько источников истины. И они спорят между собой.

Например, “шапка” в окне квестов (приветствие над списком заданий) часто живёт в quest_greeting/quest_greeting_locale.
А “просто поговорить” через gossip-меню — цепочка creature_template.gossip_menu_id → gossip_menu.TextID → npc_text/npc_text_locale.

Мы как раз прошли этот лабиринт на живом сервере. На одних NPC срабатывал npc_text_locale, на других — quest_greeting_locale.
А самый коварный случай выглядел так: локали загружаются, но не применяются.
И тут начинается настоящая инженерная часть истории.

Сервер всё грузит. База всё хранит. Почему всё равно английский?

В логах worldserver было красиво и уверенно:
“Loaded quest greeting Locale Strings…”, “Loaded Npc Text Locale Strings…”.
База тоже честно показывала русские строки в quest_greeting_locale и npc_text_locale.
А клиент — упрямо показывал английский.

Тут легко сорваться в “перегрузи сервер” как универсальный обряд. Иногда помогает — и этим подкармливает мифологию.
Но правильный путь другой: доказать, где именно ломается выбор строки.

Самая опасная ложь в администрировании — “наверное”.
Лечение “наверное” почти всегда превращается в ритуал.

Мы пошли от обратного: временными “маркерными” строками доказали, какой источник реально используется,
и где именно код выбирает не ту локаль. Да, это звучит как шаманство. Но это не шаманство — это трассировка реальности.

Один маленький индекс, который ломал ruRU

Финальная развязка оказалась классической: неправильный доступ к локализованной строке по индексу.
Сервер хранил локализованные greeting-строки в контейнере, который заполнялся через AddLocaleString().
А в месте, где формировался пакет с приветствием, код пытался взять строку так, будто локали лежат в массиве строго по enum-индексу:
Greeting[locale].

И вот тут начинается тот самый “призрак”: в одних подсистемах локали извлекались правильно (через helper-функцию),
в других — по наивной логике “locale=8 значит возьмём восьмой элемент”.
Если контейнер хранит локали “плотно” (или просто не гарантирует размер TOTAL_LOCALES),
то ruRU легко проваливается в fallback на DEFAULT_LOCALE.
И вот тебе снова английский.

Правильный подход в AzerothCore уже был в соседних строках: для названий квестов использовался
ObjectMgr::GetLocaleString(...). То есть проект сам показывал, как надо.
Мы просто заставили greeting работать по тому же правилу.

Романтика “утёкших кодов” обычно заканчивается там, где начинается банальный индекс вектора.
Это не делает историю менее красивой. Это делает её честнее.

Наши правки: коротко и по делу

Мы работали на ветке npcbots_3.3.5 (AzerothCore), собрали новый worldserver,
и поправили выбор локализованного приветствия в месте формирования списка квестов у questgiver.
По сути — перестали обращаться к локали напрямую по индексу и начали использовать стандартный helper.

Файл:
src/server/game/Entities/Creature/GossipDef.cpp

Идея фикса:

// Было: попытка взять локаль по индексу (ломалось на ruRU=8)
LocaleConstant locale = _session->GetSessionDbLocaleIndex();
if (questGreeting->Greeting.size() > size_t(locale))
    strGreeting = questGreeting->Greeting[locale];
else
    strGreeting = questGreeting->Greeting[DEFAULT_LOCALE];

// Стало: стандартный путь, как в остальном ядре
LocaleConstant locale = _session->GetSessionDbLocaleIndex();
strGreeting = questGreeting->Greeting[DEFAULT_LOCALE];
if (locale != LOCALE_enUS)
    ObjectMgr::GetLocaleString(questGreeting->Greeting, locale, strGreeting);

После этого локализация quest_greeting_locale перестала притворяться декоративным элементом и стала реально работать.
А мы наконец перестали спорить с призраками — и начали спорить с фактами, что гораздо продуктивнее.

Почему это “путешествует” между ядрами

Не нужно верить в “слитые коды”, чтобы увидеть закономерность. Достаточно помнить, что:
люди копируют решения, патчи переезжают из проекта в проект, архитектурные привычки закрепляются.
Одни и те же участки логики пишутся разными людьми в разных репозиториях — но одинаковыми руками,
под одинаковые данные и с одинаковыми ожиданиями.

И если где-то закрепилась привычка “индекс локали = индекс массива строк”, она будет всплывать снова.
До тех пор, пока кто-то не скажет: “стоп, а как ядро само рекомендует доставать локали?” —
и не сделает маленький фикс, который возвращает систему к здравому смыслу.

В конечном счёте сервер — это не магия и не миф.
Это договор между базой, кодом и клиентом.
И когда договор нарушается, виноват обычно не древний заговор, а маленькая нестыковка в трактовке данных.

Практическая мораль для админа

Если завтра у тебя снова вылезет “английский хвост”, не надо начинать с молитв и перезапусков.
Начинай с вопроса: какой источник текста сейчас используется?
Quest Greeting? Gossip через TextID? CreatureText? BroadcastText?

Когда источник найден, у тебя появляется власть. Когда он не найден — у тебя только надежда.
А надежда в администрировании полезна примерно так же, как и “авось проскочим” в продакшене.


P.S. Мы сохранили сервер как бандл: дампы баз, runtime, исходники с патчем и сборку — чтобы этот фикс не растворился во времени.
Потому что самый обидный баг — тот, который ты уже победил, но забыл, как именно.