Skip to content

План Наполеон: перевод Былин на UTF-8 (исходники и рантайм) #3681

Description

@kvirund

Цель

Разработчики работают с исходниками локально в UTF-8 и мыслят в UTF-8. Следствие: все строки в рантайме тоже становятся UTF-8. Легаси-клиентам (Alt/CP866, Win-1251, KOI8-R) их кодировка отдаётся на сетевой границе — непредставимые символы транслитерируются.

Состояние

Треки A, C и D закрыты полностью. Исходники и рантайм в UTF-8, развилки по кодировке в коде не осталось: ни флага, ни #ifdef, ни функции-предиката. Всё в PR #3709 (черновик, 53 коммита, история линейная).

Осталось: трек B — файлы мира и конфиги на диске, по решению вынесен отдельно.

Принцип декомпозиции

Ломается при переходе не сама кодировка, а байтовая семантика: длина, регистр, ширина, срезы строк считались в байтах, что под KOI8-R совпадает с символами, а под UTF-8 — нет. Поэтому: сначала перевод семантики на символьную (безопасный рефактор, пока рантайм ещё KOI8-R), потом атомарный флип, потом чистка.

Хранилище git и так было в UTF-8 (working-tree-encoding=KOI8-R перекодировал только в локальное рабочее дерево). Поэтому сам флип исходников оказался диффом в восемь файлов конфигурации и ноль строк кода.

Этапы

A. Символьная семантика

  • A0. src/utils/utf8.{h,cpp} + юнит-тесты на UTF-8-фикстурах: длина в кодовых точках, substr, char_at, decode/encode, is_valid, кириллический fold. Плюс audit-скрипт tools/audit_utf8_migration.py.
  • A1. Слой native_text (char_count/capitalize_first/truncate_offset/char_bytes). Промаршрутизированы format_text, пейджер next_page, string_add.
  • A2. Регистр, ctype и сравнение: str_cmp/strn_cmp, isname (~157 сайтов), сплиттеры аргументов, GetCase, ctype-таблицы (проаудированы все 82 call-site, чинить пришлось пять сканеров пробега букв).
    • Дифференциально проверено: под KOI8-R — 0 расхождений; под UTF-8 чинятся обе стороны дефекта («щит» матчил «меч длинный» по общему ведущему байту; «МЕЧ» не матчил «меч»).
    • Побочно закрыт тех-долг: fname и цикл алиасов в im.cpp копировали в фикс-буферы без проверки границ; в dg_scripts сырой отрицательный char уходил в isspace() (UB).
  • A3. Колоночная вёрстка: libfort (utf8_table), WHO/WHERE/умения/аффекты, score, spells, features, exits, PK-лист, крафт, god-команды.
    • Уточнение объёма: fmt::format("{:<20}") мигрировать не нужно — замерено: для валидного UTF-8 он считает кодовые точки, для KOI8-R — по единице на байт, т.е. уже корректен в обеих кодировках.
  • A4. Сплошная сверка аудитом (трек A был закрыт преждевременно).
    • 254 символьных литерала с кириллицей в 19 файлах (case 'А':) — блокер флипа: под UTF-8 это multi-character constant, меню и OLC молча перестали бы отвечать. Переведены на строковые литералы и first_char_code.
    • 37 мест «заглавная первая буква» → capitalize_first.
    • 18 побайтных циклов лоуэрения → посимвольные.

C. Флип

  • C1a. Имя файла сохранёнки. get_filename транслитерировал побайтно — под UTF-8 файлы всех существующих игроков перестали бы находиться. Вынесено в native_text::translit_to_filename, отображение извлечено из настоящих таблиц; тест пинит все 33 буквы в обоих регистрах.
  • C1. Граница диска. Чтение: from_disk_line/from_disk_text — корректный UTF-8 считается уже нативным, остальное перекодируется (кириллица KOI8-R почти никогда не валидный UTF-8 → надёжный дискриминатор без поля версии). Запись: to_diskсимметрично чтению, на диск уходит KOI8-R.
  • C2. Флип. working-tree-encoding снят с /src, /tests; блобы не изменились. Опция internal_encoding удалена: собрать байтовый вариант из UTF-8-исходников нельзя, это одно состояние, а не два. MSVC переведён на /source-charset:utf-8. Из Dockerfile и CI убрана перекодировка дерева iconv'ом, которая держала UTF-8-сборку до флипа.
  • C3a. Все источники KOI8-R-данных, а не только мир. cfg/** (XML), справка, доски, почта, сейвы. Точка входа — native_text::read_data_file.
  • C3. Граница и клиенты. UTF-8-клиентам passthrough; легаси — to_koi8 + словарь транслитерации (473 записи: тире → дефис, ёлочки → кавычки, диакритика → базовая буква, псевдографика → обычная). Инвариант «замена не длиннее исходного символа в байтах» закреплён тестом.
  • C4. Границы, где перекодировки больше нет. Телега, Admin API (JSON), обсервабилити, запись в SQLite — все четыре переводили текст движка из KOI8-R в UTF-8 для потребителя, который и так ждёт UTF-8. Убрано.

D. Пост-чистка

  • Удалены KOI8-R-реализации примитивов, флаг internal_encoding, макрос INTERNAL_ENCODING_UTF8 и предикат native_is_utf8() (−375 и −218 строк двумя заходами). В тестах свёрнуто 16 ветвлений и три GTEST_SKIP.
  • Обновлены CLAUDE.md, .gitattributes, tools/meson/generate_version.py, хук pre-commit (теперь сторожит обе стороны: исходники обязаны разбираться как UTF-8, файлы мира — оставаться в объявленной кодировке). iconv-воркфлоу для src/ отмер.
  • Выкинуты ставшие тождественными обёртки Koi8rToUtf8 и EngineStringToUtf8 вместе с 69 вызовами.
  • Удалён мёртвый pred_separator из utils.h и enum separator_mode, существовавший только ради него.

B. Файлы мира в UTF-8 (отдельно)

  • Конвертировать YAML-мир и конфиги в UTF-8, отказаться от Koi8rYamlEmitter. После этого умирает граница диска: from_disk_*, to_disk, from_koi8, from_utf8 выродятся в тождество.

Что нашлось по дороге и не было в плане

Все — один класс: байтовая семантика там, где нужна символьная. Ловились не аудитом, а на живом сервере.

  • Конфиги росли вдвое на каждой загрузке. Документ приводился к нативной кодировке один раз в DataNode, но перекодировка осталась ещё и на каждом поле в parse::AttrStr. Под KOI8-R оба преобразования — тождество, под UTF-8 каждое поле переводилось дважды. Так как загрузчик нормализует файл через save(), порча накапливалась: cfg/mechanics/obj_sets.xml дорос со 120 КБ до 6,7 ГБ, и старт умирал по памяти. Закреплено тестом на идемпотентность цикла «загрузили — записали».
  • Отсутствие границы записи. Движок писал файлы в нативной кодировке, то есть первый же старт молча переводил в UTF-8 всё, к чему прикоснулся, и откат на старую сборку становился невозможен. Отсюда появился to_disk.
  • Имена файлов строились из нативных байтов. CreateFileName и четыре копии в house.cpp: рядом со 105 досками завелось 70 новых с кириллицей в имени; у дружин так же потерялись бы сундук, mod и pk-лог.
  • Справка. Файловая читалась без границы (разделы не находились, тело уезжало мусором); динамическая портилась на сортировке — SortKoiString перегонял список в Windows-1251 прямо в строках.
  • Одобрение имени не находило персонажа: name_convert поднимал регистр первого байта.
  • Карта обрывалась на первой пустой строке — из-за этого «карта богов» с глубиной 25 выглядела не крупнее обычной.

Побочные эффекты

  • Сортировка русских списков стала алфавитной (кодовые точки UTF-8 = порядок А-Я; KOI8-R сортировал по байтам, разложенным под латинскую транслитерацию). Момент, когда sort_key уйдёт, — хороший повод сделать сортировку по-настоящему правильной: «ё» должна идти сразу за «е», чего не делает ни один из двух порядков.
  • Появилась возможность типографики и псевдографики в игровом тексте: для UTF-8-клиентов как есть, для легаси — через словарь замен.

Что вскрылось на боевом

Отдельные тикеты, заведённые по ходу обкатки ветки. Все закрыты, здесь -- чтобы картина была
в одном месте: это не независимые баги, а следствия одного перехода.

тикет что класс
#3749 миграция полей объектов метила зоны на каждом буте логика, не кодировка
#3751 падение в CalcGeneralSaving буфер фиксированного размера с русским текстом
#3752 падение на «осмотреть суму» то же

Класс, который дал два падения из трёх. sprintf в буфер фиксированного размера, где
русский текст лежит в самой строке формата. В KOI8-R фраза укладывалась впритык, в UTF-8 те же
символы занимают вдвое больше, и fortify валит процесс. Числа в коде при этом нет -- растёт сам
литерал, поэтому поиском по «подозрительным константам» такое не находится.

Ловится компилятором: -Wformat-overflow (входит в -Wall) на форме #3751 пишет
directive writing 170 bytes into a region of size 162. То есть предупреждение всё это время
лежало в логе сборки. Дерево собирается с -Werror=format-overflow чисто -- предлагаю включить,
чтобы такое не собиралось вовсе.

Отдельно стоит #3760 -- «где» упиралось в буфер вывода. Это не регрессия перехода:
команда шлёт все совпадения по миру одним куском без ограничения, и так с мая. UTF-8 лишь
понизил порог, потому что kLargeBufSize задан в байтах.

Проверено

  • Сборка и тесты зелёные: 676 тестов, 673 прошло, 0 упало (3 пропуска — старые Vedun_*, к миграции отношения не имеют).
  • Живой прогон на копии боевого мира (world.20260802.tgz, 609 зон): вход из koi8/alt/win/utf-8, создание персонажа и склонения, справка файловая и динамическая, доски, почта, сейвы, карта.
  • Мир на диске остаётся KOI8-R. Прогон на свежем дампе боевого сервера (world.20260817.tgz) с игроками: игра, создание персонажа, дружины, обменник, посылки, титул, склад — движок переписал 51 файл, ни один не сменил кодировку на UTF-8. Первые два захода этот скан ловил утечки (обменник и посылки, потом .pkl/.stuff дружин) — они и стали коммитами c68254e и b38b68c.
  • Боевой прогон 18.08 вскрыл то, что этот скан пропускал. Проверка искала файлы, ставшие
    UTF-8, а порча выглядит иначе: кои-восьмые байты, прочитанные мимо границы, уходят в to_koi8,
    тот разбирает их как Latin-1 и гонит через словарь транслита -- получается валидный KOI8 из
    латиницы («верий.свет» -> «AIEUAxAOA.OxAO»), и скан на кодировку его не видит. Скан теперь
    считает ещё и кириллицу в каждом файле. Так нашлись: файл вещей игрока, сундук дружины,
    списки состояния. Порча необратима -- транслит склеивает разные буквы в одну латинскую.
  • Откат проверен, а не обещан. До-флиповый бинарь мастера (6da0ff0) запущен на том самом мире, который трогала UTF-8-сборка: грузится, пускает в игру, дружины и счёт читаются без искажений. Возврат на KOI8-R-сборку файлы мира и игроков не ломает.

Metadata

Metadata

Assignees

Labels

EpicIt is a large task.utf8Переход на UTF-8 (issue #3681) и его последствия

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions