feat(utf8): граница кодировки на загрузке данных (C3a, #3681) - #3690
Closed
kvirund wants to merge 27 commits into
Closed
feat(utf8): граница кодировки на загрузке данных (C3a, #3681)#3690kvirund wants to merge 27 commits into
kvirund wants to merge 27 commits into
Conversation
…(C3a, #3681) Wiring AttrStr() alone was not enough -- class names, and anything else that reads an element value rather than an attribute, bypassed it. Converting per field would mean finding every reader; converting per document is one place and cannot be forgotten, so DataNode now reads the file itself, passes it through native_text::from_koi8() and hands the buffer to pugixml. Identity under KOI8-R. Also: * from_koi8() gains an ASCII fast path. It runs per field during boot and most fields (keys, aliases, numbers) are pure ASCII, where the two encodings agree byte for byte. * A unit test for from_koi8 in both directions -- it had none, which was an omission: the wrapper is a no-op today but perfectly testable, including under the flip build. * help.cpp built a class list and then wrote '\0' over the trailing newline. That leaves a NUL *inside* a std::string without changing its length; the byte-based table tolerated it, libfort's utf8_table walks off the end and aborts. Now pop_back(). Milestone: with these, the flip build boots the production world without SYSERR. Text still arrives mangled at the client, for two reasons already on the C3 list and not yet done -- the descriptor still runs koi_to_utf8() for UTF-8 clients (a second conversion of text that is already UTF-8), and plain-text data files (greetings, help) have no boundary yet. Both builds green: KOI8-R 653 passed / 0 failed and the production world still boots.
…C3, #3681) The descriptor converted KOI8-R to the client's code page on the way out and back on the way in. Under a UTF-8 runtime both directions were wrong: text already in UTF-8 was run through koi_to_utf8 a second time (which is exactly what the first flip build showed on screen), and a UTF-8 client's input was converted down to KOI8-R. Output: legacy code pages are KOI8-R -> target byte tables, so the text is brought to KOI8-R first (a no-op under KOI8-R) and the tables are left untouched; a UTF-8 client now gets the text as is when the runtime is already UTF-8. Input: the per-byte tables still produce KOI8-R, so the assembled line is lifted to the native encoding afterwards; a UTF-8 client's line is left alone when the runtime is UTF-8 instead of being converted down. Also fixes the legacy zMUD 'z' substitution: at that point the buffer still holds KOI8-R (that is what the tables above produce), so the letter has to be KOI8-R too -- it is now taken through to_koi8() once instead of being pasted in the source encoding. Adds native_text::to_koi8(), the inverse boundary, with a round-trip test. KOI8-R build: 654 passed / 0 failed.
…erals (#3681) The six conversion tables (AltToKoi, KoiToAlt, WinToKoi, KoiToWin, KoiToWin2, AltToLat) were written as string literals made of high-byte characters, so their contents depended on the encoding of the source file. With UTF-8 sources every one of those characters becomes two or three bytes and the tables silently grow past their 128 entries: AltToKoi 296, KoiToAlt 298, WinToKoi 214, KoiToWin 218, KoiToWin2 217, AltToLat 248 Indexing them then reads whatever follows, so every legacy client (Alt/CP866, Windows-1251, zMUD) would have received garbage after the flip -- and AltToLat also feeds save-file name transliteration. Nothing would have failed to build or crashed; the output would just have been wrong. The tables are now \xNN escapes, which are plain ASCII and mean the same in any source encoding. Byte-for-byte identical to what the KOI8-R build produced before -- verified against the previous contents, all six tables, 128 bytes each. Found by connecting an Alt client to the flip build: its text came out mangled while the UTF-8 and KOI8-R clients were already correct. The tables looked innocent because they are untouched legacy code -- it is the source encoding that changes underneath them. KOI8-R build: 654 passed / 0 failed.
…3681) Completes the data side of C3 so the engine can actually run on UTF-8: * The eight loaders that called pugixml's load_file() directly (craft, named_stuff, shop_ext, sets_drop, mail, mob_stat, glory_const, db) now read through native_text::read_data_file() and parse a buffer, like the DataNode path already did. * Files read via FBFILE -- player saves above all -- are converted when the file is opened rather than line by line. Line-by-line would have been the obvious place, but fbgetline() has no idea how large the caller's buffer is, and Cyrillic grows when it is transcoded, so it could overrun it. At open time the size is ours to control. * The original bytes are kept in FBFILE::raw for the player-file CRC, which is computed over the file as it is stored; checksumming the converted text would fail every load. * native_text::from_disk_line() implements the read-both rule: text that is already well-formed UTF-8 is taken as native, anything else is transcoded. Cyrillic in KOI8-R is essentially never valid UTF-8, so validity is a dependable discriminator and old and new save files can coexist without a version field. Dockerfile gains INTERNAL_ENCODING (koi8r default). For utf8 it transcodes the C++ tree in place with iconv rather than re-checking-out: the build context may be a worktree, where .git is a link file, or an exported archive with no git at all. KOI8-R build: 654 passed / 0 failed.
_parse_name() and parse_exist_name() walked the name a byte at a time and rejected it unless every byte passed a_isalpha(). A multibyte letter fails that on its trailing byte, so under UTF-8 every Russian name was refused -- the login screen just asked for the name again, including for characters that already exist. Both now step character by character. The old "*argument > 0" test meant "this character is not ASCII" (a high byte is negative as a signed char); it is now a direct check on the code point. Case folding is likewise per character -- first letter upper, the rest lower -- and stays length-preserving, so the destination buffer cannot overflow. Found by connecting to the UTF-8 container and typing an existing character's name. KOI8-R build unchanged: 654 passed / 0 failed, and an existing name is still recognised.
players.lst is read with plain fopen/get_line, so it bypassed the FBFILE boundary and the index kept KOI8-R names while the runtime ran on UTF-8. Every existing character then looked new: the login screen offered to create "Дрегвий" instead of asking for a password. The name now goes through the boundary as it is read. Found on the UTF-8 container by typing the name of a character that exists in the production world -- the KOI8-R build recognised it, the UTF-8 one did not. 654 passed / 0 failed.
The index's hasher and comparator lowered the name a byte at a time through the KOI8-R table. Under UTF-8 that table leaves a multibyte letter untouched, so "Дрегвий" and "дрегвий" hashed differently and the exact lookup missed: an existing character was offered as a new one at the login screen. The prefix check next to it uses strn_cmp, which is already character-aware, so it still matched -- which is why the symptom was "your name matches an existing character" rather than a clean miss. Both now fold per character: the hasher over a lowered copy, the comparator through native_text::compare_ci, so the pair stays consistent. Isolated by pointing both builds at the same freshly extracted world: KOI8-R recognised the name, UTF-8 did not, which ruled the data out. 654 passed / 0 failed, and the KOI8-R build still recognises the same name.
…#3681) ActualizePlayersIndex() lowercased the name a byte at a time. For "г" the second UTF-8 byte is 0xB3, which the KOI8-R table reads as "Ё" and lowers to 0xA3 -- so the letter turned into "У". The mangled name produced a wrong save-file path, the character failed to load, and it was silently left out of the index. Measured on the production world: the index held 9117 entries under KOI8-R and only 7839 under UTF-8, i.e. 1278 characters were missing. Every one of them would have been offered their own name as "available" at the login screen. Isolated by counting index insertions in both builds (2 skips vs 1280) and dumping the names that were skipped -- "свароУ", "сиУурд", "раУнар" made the corrupted letter obvious. 654 passed / 0 failed.
…r builds (#3681) "version" now prints the encoding the engine actually runs on. It is read from native_text at runtime rather than baked in as a build string, so it cannot drift from the truth. The revision was empty in container builds (and the release counter fell back to 0, hence "0.1.32.0"). git is installed in the builder, but the build context is a worktree whose .git is a link file pointing outside it, so rev-parse produced nothing. Both values can now be passed in through the environment, which also covers builds from an unpacked archive; the Dockerfile forwards them as GIT_REV / GIT_COUNT build args. Without git and without them the revision reads "unknown" instead of being blank. Build the image with: docker build --build-arg GIT_REV=$(git rev-parse --short HEAD) \ --build-arg GIT_COUNT=$(git rev-list --count HEAD) ...
…ry (C3, #3681) Board files and the changelog are read straight from std::ifstream, so they bypassed the FBFILE boundary and their text stayed KOI8-R while the runtime ran on UTF-8. Author, subject and message body now cross the boundary as they are parsed, as does every line of the changelog. Identity under KOI8-R. 654 passed / 0 failed.
tests/boards.news.cpp never asserted anything -- it is a placeholder waiting on global state to be untangled -- so the board file path had no coverage at all, even though news.sample carries Russian text. The new test loads that sample through the real Board::Load() and checks the messages come out in the engine's native encoding: valid UTF-8 exactly when the runtime is UTF-8, KOI8-R otherwise. It also matches an author name literally, which fails loudly if the text arrives in the wrong encoding. Meaningful in both builds: the literals compile in whatever encoding the engine runs on.
…3681) Клиент в KOI8-R/CP866/Win-1251 получает текст через KOI8-R, а движок под UTF-8 может держать в строках то, чего в KOI8-R нет: типографское тире, «ёлочки», латиницу с диакритикой, знак евро. Раньше всё это одинаково схлопывалось в один символ-заглушку, хотя читателю большая часть из этого была бы понятна. Добавлена таблица ближайших соответствий (333 записи, сгенерирована и сверена с кодеком KOI8-R: каждая запись действительно отсутствует в KOI8-R, латиница взята из NFKD со снятыми комбинирующими знаками). Подстановка идёт предпроходом по UTF-8 до конвертации -- именно это позволяет замене быть длиннее одного символа ("..." для многоточия, "(tm)", "EUR"), чего сам конвертер не умеет: он пишет ровно один байт на кодовую точку. Заглушка сменена с '+' на '?': плюс в игре -- обычный текст ("+5 к урону"), и потерянный символ читался как содержимое.
Шапка входа в игру осталась такой же, какой была во времена, когда весь текст жил в одном байте: рамка из дефисов, кавычки-палочки, дефис вместо тире. Под UTF-8 движок может отдать её нормально -- скруглённая рамка, типографское тире, кавычки-лапки, сплошные блоки в логотипе. Текст лежит отдельным файлом lib/text/greeting.utf8, а не в system_msg.xml, потому что XML пока в KOI8-R и половину этих символов просто не вмещает. Файл читается сырым и уходит клиенту байт в байт -- поэтому показывается только при сочетании "движок нативно в UTF-8" + "клиент выбрал UTF-8": лишь тогда текст не проходит через перекодировку. Во всех остальных случаях отдаётся прежняя шапка, так что для игроков на alt/win/koi8 ничего не меняется. Файл необязателен: если его нет или в нём битый UTF-8 -- в лог уходит SYSERR и берётся прежняя шапка.
Файлы справки лежат на диске в KOI8-R, а DataFile::get_one_line отдавал строки как есть -- под UTF-8-движком в память попадали байты чужой кодировки. Из-за этого разделы не находились (ключ раздела не совпадал с тем, что ввёл игрок, приведённым к нативной кодировке), а тело найденного раздела уезжало клиенту мусором. Строка теперь проходит через native_text::from_disk_line. Под UTF-8 она при этом может вырасти (байт KOI8-R разворачивается максимум в три байта), поэтому get_one_line принимает размер буфера, а буферы у вызывающего с тройным запасом. Заодно перевод строки снимается только если он там есть: прежний код рубил последний символ безусловно и терял его на строках без завершающего '\n'.
…3681) Было две шапки: нарядная для UTF-8-клиента и старая для всех остальных. Теперь одна -- lib/text/greeting.utf8, написанная полной палитрой. Клиенту в alt/win/ koi8 (и всей KOI8-R-сборке) она доезжает уже приведённой к KOI8-R: скруглённые углы рамки становятся обычными, тире -- дефисом, кавычки-лапки -- палочками. Проверка кодировки клиента из login.cpp ушла: приведением занимается граница вывода, которая и так через to_koi8 проходит. Чтобы деградация была осмысленной, а не сплошной заглушкой, словарь дополнен псевдографикой (140 записей): скруглённые и жирные рамки сводятся к обычным, дробные блоки -- к целым, звёздочки -- к '*'. Заодно закреплён инвариант "замена не длиннее исходного символа в байтах": вызывающие рассчитывают, что перевод в KOI8-R строку не удлиняет, а четыре записи его нарушали ('(tm)', '(R)', 'GBP', 'JPY'). Инвариант проверяется тестом по всей таблице. Сама перекодировка вынесена в codepages::Utf8ToKoi8 -- одна реализация на обе сборки вместо копии в каждой ветке #ifdef.
Раскладка списка заклинаний по колонкам решалась арифметикой по накопленной длине строки в байтах. Под UTF-8 байт на символ уже не один, поэтому перенос случался не там, где надо, и колонки разъезжались. Длина в буфере (смещение для strcpy) и длина в символах разведены: первая осталась байтовой, вторая считается через native_text::char_count и решает раскладку. Третья ветка переведена с sprintf на fmt -- printf меряет %-30s в байтах, а fmt для корректного UTF-8 меряет ширину поля в символах.
utils::SortKoiString перегонял список в Windows-1251 прямо в строках (там русские буквы стоят по алфавиту, в отличие от KOI8-R), сортировал и перегонял обратно. Под UTF-8 это разрушало текст: побайтовая таблица KOI8-R -> Windows-1251 для многобайтовой строки не значит ничего, а обратное преобразование уже не восстанавливало исходник. Поэтому "справка умениябогатыря", "способностикупца" и прочие собираемые на лету разделы выезжали мусором, хотя обычная справка из файлов читалась нормально. Сортировка теперь идёт по ключу (native_text::sort_key), а сами строки не трогаются. Ключ даёт ровно тот же порядок, что и прежний трюк, и одинаков в обеих сборках -- закреплено тестом. Тот же ручной трюк, повторённый в help.cpp через SubstKtoW/SubstWtoK, убран. Заодно ширина колонок в этих разделах: std::setw меряет в байтах, поэтому под UTF-8 колонки не сходились. Переведено на fmt, который для корректного UTF-8 меряет ширину поля в символах.
Символы карты записаны в UTF-8 и приводятся к нативной кодировке один раз при первом обращении: под UTF-8 это тождество, под KOI8-R -- перекодировка со сведением через словарь. Клиент в UTF-8 видит тонкие, жирные и пунктирные линии и треугольники переходов вверх-вниз, клиент в koi8/alt -- обычную псевдографику KOI8-R. Ширина не меняется: каждая замена ровно в один символ, так что раскладка карты в обеих кодировках одна и та же. Язык рисунка прежний -- разрывы значат "пройти можно", сплошная "нельзя", -- но теперь он читается: открытый проход тонкий с разрывом, дверь с перекладиной, скрытый (видят только боги) пунктиром, стена жирная. Вода стала '≈', полёт '°' вместо запятой и апострофа.
Обрезка пустых строк искала не первую и последнюю непустую строку, а первый
разрыв: как только после начала карты попадалась строка без единой клетки,
отрисовка на ней и заканчивалась. А разрыв посередине -- дело обычное, комнаты
редко стоят плотным прямоугольником. Чем больше глубина, тем вероятнее разрыв,
поэтому "карта богов" с глубиной 25 выглядела не крупнее обычной с глубиной 5.
Ровно та же правка уже сделана для правой границы по столбцам ("иначе клетки
дальше теряются") -- по строкам её забыли.
…я и учителя Начертание выбирается по кодировке клиента, а не отдаётся словарю транслитерации. Словарь подбирает замену по похожести символа, а карте нужна замена по смыслу и строго той же ширины: домик постоя должен стать 'R', а не '#', стрелка перехода в зону -- '>', а не '->' в два знака (карта бы разъехалась ровно так же, как рамка на лигатуре). UTF-8-клиенту: '→' переход в другую зону, '○' мирная комната, '⌂' постой, '★' учитель, плюс прежние линии и треугольники. Всем остальным -- то же самое прежними знаками: '>', '~', 'R', 'T'. Буквы у лавки, банка, почты, конюшни, обмена и гривен оставлены: там буква несёт мнемонику, которую значок теряет, а конверт и монета вдобавок эмодзи-представимые и в части клиентов занимают две колонки.
Псевдографика в клиенте пользователя выглядела съехавшей. Геометрию на сервере я померил: длина строк в символах в UTF-8 и KOI8-R совпадает знак в знак, а каждая трёхсимвольная связь стоит ровно на c-1..c+1 относительно колонки своей комнаты -- то есть на стороне движка ничего не смещается. Остаётся шрифт клиента: жирные и пунктирные линии (U+2501, U+2504 и родня) в моноширинных шрифтах редки и подтягиваются из шрифта-заменителя с другой шириной знакоместа. Красоту откатываем целиком: символы карты снова '-', '|', ':', '^', 'v'. Правка обрезки пустых строк остаётся -- она про обрыв карты на первом разрыве и к символам отношения не имеет.
…#3681) Содержимое XML-конфига приводится к нативной кодировке один раз на документ (в DataNode), но перекодировка осталась ещё и на каждом поле, в parse::AttrStr. Под KOI8-R оба преобразования -- тождество, и это ничего не стоило. Под UTF-8 каждое поле переводилось дважды: байты уже готового UTF-8 разбирались как KOI8-R и разворачивались повторно. Само по себе это было бы только порчей текста, но конфиги движок ещё и пишет обратно (ObjSetsLoader::Load в конце нормализует файл через save()). Поэтому порча накапливалась: файл рос примерно вдвое на каждой загрузке. cfg/mechanics/obj_sets.xml дорос со 120 КБ до 6,7 ГБ, и очередной старт умирал, пытаясь прочитать его в память -- это и было то самое "распухание памяти на Check big sets in rent" (падало на следующем шаге, LoadCfg("obj_sets")). Перекодировка на уровне поля убрана. Заодно чтение файла целиком приведено к тому же правилу, что и построчное: from_disk_text берёт корректный UTF-8 как уже нативный и перекодирует только то, что им не является. Без этого цикл "прочитали - записали" остаётся неидемпотентным для любого файла, который движок и читает, и пишет. Проверено на боевом конфиге: первая загрузка 120960 -> 143363 байта (честная разовая перекодировка KOI8-R -> UTF-8), вторая и третья -- те же 143363 байта, байт в байт. До правки: 120960 -> 209301 -> 365820 и дальше вдвое.
…3681) Тест повторяет ровно то, что делает загрузчик: собрать документ, записать, прочитать через DataNode + parse::AttrStr, записать снова -- и требует, чтобы второй файл совпал с первым байт в байт. Плюс отдельная проверка, что конфиг, лежащий на диске в KOI8-R, читается в нативную кодировку правильно. Проверено, что тест ловит ту самую ошибку: с возвращённой перекодировкой на уровне поля обе проверки падают, без неё проходят.
Граница чтения была, границы записи не было: движок писал файлы в нативной кодировке, и первое же сохранение переводило в UTF-8 всё, к чему он прикоснулся -- сейвы, ренту, доски, список игроков, конфиги, которые сам же нормализует при загрузке. Мир при этом объявлен неизменным, а откат на KOI8-R-сборку после такого старта уже невозможен: она прочитает эти файлы как байты KOI8-R. Добавлен native_text::to_disk -- зеркало from_disk_text. Под KOI8-R тождество, под UTF-8 приведение к KOI8-R с той же транслитерацией, что и для легаси-клиента. Проведён через все точки записи: конфиги (DataNode::Save), сейвы персонажей, рента, доски, players.lst, список неодобренных имён. Файлы, которые UTF-8 намеренно (lib/text/greeting.utf8), читаются сырыми и через эту границу не проходят.
…3681) CreateFileName и четыре его копии в house.cpp гоняли строку через байтовый AtoL. Под UTF-8 это резало русскую букву пополам и оставляло её в имени файла как есть, поэтому движок не находил уже существующие файлы и заводил рядом новые: в мире появилось 70 лишних досок с кириллицей в имени рядом со 105 старыми. У дружин последствия были бы хуже -- сундук, mod и pk-лог уехали бы в каталог с другим именем, то есть пропали бы. Строка приводится к KOI8-R и дальше идёт прежний байтовый код: под KOI8-R to_koi8 -- тождество, так что там не меняется ни байт. Копии в house.cpp и logger.cpp заменены вызовом CreateFileName. Заодно поправлены операции над ПЕРВЫМ СИМВОЛОМ, которые работали с первым байтом: name_convert (из-за неё "имя <персонаж> одобрить" не находило персонажа), первая буква направления в списке выходов, титул, имена в дружине, раса и вера в "счет".
Collaborator
Author
|
Объединено в #3709: одна ветка |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Трек C: провести данные и клиентов через границу кодировки. Под текущей сборкой всё — no-op.
Итог: сервер работает под UTF-8 на боевом мире
Контейнер на порту 4000 сейчас собран с
INTERNAL_ENCODING=utf8и крутится наworld.20260802.tgz. Проверено вживую:otel-only), кириллица читаетсяНайденные и исправленные блокеры
Каждый нашёлся только запуском настоящего бинаря на настоящем мире — юнит-тесты их не видят.
\xNN-escape'ами, байты сверены.LOWERпри построении индекса игроков. У «г» второй байт UTF-8 равен0xB3, а в KOI8-R это «Ё» → таблица опускала его в0xA3, и буква становилась «У». Имя файла получалось неверным, персонаж не загружался и не попадал в индекс: 1278 из 9117 персонажей теряли доступ к своим чарам. Найдено подсчётом пропусков в обеих сборках (2 против 1280) и дампом пропущенных имён —свароУ,сиУурд,раУнар.a_isalpha, любое русское имя отвергалось.koi_to_utf8второй раз.help.cppписал'\0'внутрьstd::string— libfort в режимеutf8_tableна этом падает (процесс, не вывод).Границы кодировки
native_text::from_koi8/to_koi8/from_disk_line/read_data_file— все с тестами. Через них проведены: YAML-загрузчик, XML (на загрузке документа), восемь прямыхload_file, файлы черезFBFILE(включая сохранёнки),players.lst, сетевой ввод и вывод.CRC файлов игроков считается по исходным байтам (
FBFILE::raw) — иначе сумма разъезжается на перекодированном тексте.Правило read-both: текст, уже валидный в UTF-8, берётся как есть, иначе перекодируется — старые и новые сейвы сосуществуют без поля версии.
Что в C ещё не сделано
+).