You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Разработчики работают с исходниками локально в 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.
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).
Уточнение объёма: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-клиентов как есть, для легаси — через словарь замен.
Что вскрылось на боевом
Отдельные тикеты, заведённые по ходу обкатки ветки. Все закрыты, здесь -- чтобы картина была
в одном месте: это не независимые баги, а следствия одного перехода.
Класс, который дал два падения из трёх.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-сборку файлы мира и игроков не ломает.
Цель
Разработчики работают с исходниками локально в 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), потом атомарный флип, потом чистка.
Этапы
A. Символьная семантика
src/utils/utf8.{h,cpp}+ юнит-тесты на UTF-8-фикстурах: длина в кодовых точках,substr,char_at,decode/encode,is_valid, кириллический fold. Плюс audit-скриптtools/audit_utf8_migration.py.native_text(char_count/capitalize_first/truncate_offset/char_bytes). Промаршрутизированыformat_text, пейджерnext_page,string_add.str_cmp/strn_cmp,isname(~157 сайтов), сплиттеры аргументов,GetCase, ctype-таблицы (проаудированы все 82 call-site, чинить пришлось пять сканеров пробега букв).fnameи цикл алиасов вim.cppкопировали в фикс-буферы без проверки границ; вdg_scriptsсырой отрицательныйcharуходил вisspace()(UB).utf8_table), WHO/WHERE/умения/аффекты, score, spells, features, exits, PK-лист, крафт, god-команды.fmt::format("{:<20}")мигрировать не нужно — замерено: для валидного UTF-8 он считает кодовые точки, для KOI8-R — по единице на байт, т.е. уже корректен в обеих кодировках.case 'А':) — блокер флипа: под UTF-8 это multi-character constant, меню и OLC молча перестали бы отвечать. Переведены на строковые литералы иfirst_char_code.capitalize_first.C. Флип
get_filenameтранслитерировал побайтно — под UTF-8 файлы всех существующих игроков перестали бы находиться. Вынесено вnative_text::translit_to_filename, отображение извлечено из настоящих таблиц; тест пинит все 33 буквы в обоих регистрах.from_disk_line/from_disk_text— корректный UTF-8 считается уже нативным, остальное перекодируется (кириллица KOI8-R почти никогда не валидный UTF-8 → надёжный дискриминатор без поля версии). Запись:to_disk— симметрично чтению, на диск уходит KOI8-R.working-tree-encodingснят с/src,/tests; блобы не изменились. Опцияinternal_encodingудалена: собрать байтовый вариант из UTF-8-исходников нельзя, это одно состояние, а не два. MSVC переведён на/source-charset:utf-8. Из Dockerfile и CI убрана перекодировка дереваiconv'ом, которая держала UTF-8-сборку до флипа.cfg/**(XML), справка, доски, почта, сейвы. Точка входа —native_text::read_data_file.to_koi8+ словарь транслитерации (473 записи: тире → дефис, ёлочки → кавычки, диакритика → базовая буква, псевдографика → обычная). Инвариант «замена не длиннее исходного символа в байтах» закреплён тестом.D. Пост-чистка
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 (отдельно)
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 ГБ, и старт умирал по памяти. Закреплено тестом на идемпотентность цикла «загрузили — записали».to_disk.CreateFileNameи четыре копии вhouse.cpp: рядом со 105 досками завелось 70 новых с кириллицей в имени; у дружин так же потерялись бы сундук,modи pk-лог.SortKoiStringперегонял список в Windows-1251 прямо в строках.name_convertподнимал регистр первого байта.Побочные эффекты
sort_keyуйдёт, — хороший повод сделать сортировку по-настоящему правильной: «ё» должна идти сразу за «е», чего не делает ни один из двух порядков.Что вскрылось на боевом
Отдельные тикеты, заведённые по ходу обкатки ветки. Все закрыты, здесь -- чтобы картина была
в одном месте: это не независимые баги, а следствия одного перехода.
CalcGeneralSavingКласс, который дал два падения из трёх.
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задан в байтах.Проверено
Vedun_*, к миграции отношения не имеют).world.20260802.tgz, 609 зон): вход из koi8/alt/win/utf-8, создание персонажа и склонения, справка файловая и динамическая, доски, почта, сейвы, карта.world.20260817.tgz) с игроками: игра, создание персонажа, дружины, обменник, посылки, титул, склад — движок переписал 51 файл, ни один не сменил кодировку на UTF-8. Первые два захода этот скан ловил утечки (обменник и посылки, потом.pkl/.stuffдружин) — они и стали коммитами c68254e и b38b68c.UTF-8, а порча выглядит иначе: кои-восьмые байты, прочитанные мимо границы, уходят в to_koi8,
тот разбирает их как Latin-1 и гонит через словарь транслита -- получается валидный KOI8 из
латиницы («верий.свет» -> «AIEUAxAOA.OxAO»), и скан на кодировку его не видит. Скан теперь
считает ещё и кириллицу в каждом файле. Так нашлись: файл вещей игрока, сундук дружины,
списки состояния. Порча необратима -- транслит склеивает разные буквы в одну латинскую.
6da0ff0) запущен на том самом мире, который трогала UTF-8-сборка: грузится, пускает в игру, дружины и счёт читаются без искажений. Возврат на KOI8-R-сборку файлы мира и игроков не ломает.