Skip to content

feat(utf8): переход движка на символьную семантику и UTF-8 (#3681) - #3709

Open
kvirund wants to merge 119 commits into
masterfrom
kvirund/utf8-migration
Open

feat(utf8): переход движка на символьную семантику и UTF-8 (#3681)#3709
kvirund wants to merge 119 commits into
masterfrom
kvirund/utf8-migration

Conversation

@kvirund

@kvirund kvirund commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Объединяет две прежние ветки (#3682 + #3690) в одну, история линейная, мердж-коммитов нет.

Что здесь

Трек A — символьная семантика. utils/utf8.* (примитивы), utils/native_text.* (слой, который под KOI8-R работает по байтам, под UTF-8 по символам), utils/russian_keys.h (буквы как константы для switch). Всё, что раньше делало LOWER(*s), s[0] = UPPER(...), strlen как ширину, переведено на символьные операции.

Трек C — граница кодировки. Одно место на чтение (from_disk_line / from_disk_text / read_data_file) и одно на запись (to_disk). Под KOI8-R обе — тождество. Под UTF-8 чтение приводит к нативной, запись возвращает в кодировку диска, так что мир на диске остаётся KOI8-R и откат на старую сборку безопасен.

Транслитерация. utils/translit_koi8.* — 473 записи: то, чего в KOI8-R нет, сводится к ближайшему (тире → дефис, ёлочки → кавычки, диакритика → базовая буква, псевдографика → обычная), и только совсем безнадёжное становится ?. Инвариант «замена не длиннее исходного символа в байтах» закреплён тестом.

Как включается

Рантайм-кодировка задаётся -Dinternal_encoding=koi8r|utf8, по умолчанию koi8r — поведение не меняется. UTF-8-сборка требует исходников в UTF-8 (в CI это отдельная работа utf8-flip, в контейнере — перекодировка на этапе сборки).

Проверено

  • Обе сборки: тесты зелёные (669 / 671).
  • UTF-8-сборка поднята на копии боевого мира: вход из koi8/alt/win/utf-8, создание персонажа, склонения, справка (файловая и собираемая на лету), доски, почта, сейвы, карта.
  • Цикл «загрузили — записали» идемпотентен: после старта и сохранений в мире не появилось ни одного нового UTF-8-файла.

Чего здесь нет

Флипа исходников (они по-прежнему KOI8-R в git) и перевода мира на диске — это отдельные шаги.

kvirund added 27 commits August 17, 2026 07:50
…3681)

First step of the KOI8-R -> UTF-8 migration (track A0). Adds an encoding-agnostic
UTF-8 layer that later turns byte semantics into character semantics -- no runtime
behaviour change yet; nothing in the engine calls it.

- src/utils/utf8.{h,cpp}: decode/encode, is_valid, length, byte_offset, char_at,
  substr and ASCII+Cyrillic (incl. Yo) case folding. Code-point based, no external
  deps (no iconv/ICU); ASCII passes through unchanged.
- tests/utf8.cpp: 13 GTest cases (empty, incomplete sequences, 4-byte, BOM,
  overlong, surrogates, range edges, Cyrillic length/substr/case folding).
- tools/audit_utf8_migration.py: triage of byte-vs-char sites, categorised and
  prioritised by files that contain Russian text.

New sources are pure ASCII (UTF-8 test data spelled as \xNN escapes), so they
satisfy the KOI8-R working-tree attribute unchanged.
#3681)

Track A1 of the KOI8-R -> UTF-8 migration. Introduces the build-time
`internal_encoding` switch and a native-encoding dispatch layer, then routes the
text reformatter through it. No behaviour change on the default KOI8-R build
(char_count == byte count, capitalize_first == UPPER of the first byte,
truncate_offset == maxlen); the utf8 build gets code-point semantics.

- meson_options.txt / meson.build: internal_encoding=koi8r|utf8, the utf8 value
  defines INTERNAL_ENCODING_UTF8.
- src/utils/native_text.{h,cpp}: char_count / capitalize_first / truncate_offset
  with a byte-identical KOI8-R branch and a utf8:: branch, plus native_is_utf8().
- src/utils/utils.cpp: format_text() width accounting, first-letter capitalisation
  and maxlen truncation no longer assume 1 byte == 1 character.
- tests/native_text.cpp: adaptive GTest suite branching on native_is_utf8().
…text (#3681)

Continues track A1. next_page() now counts one column per character and steps over a
multibyte character's trailing bytes; string_add()'s truncation points snap to a
character boundary so a cut never splits a character. No-op under KOI8-R
(char_bytes == 1, truncate_offset == the byte limit) -- byte-identical behaviour.

- native_text: add char_bytes() (byte length of the character starting at a pointer).
- src/engine/ui/modify.cpp: next_page() column counting; string_add() max_str/80
  truncation offsets.
- tests/native_text.cpp: char_bytes cases (ASCII, Cyrillic lead, 4-byte, truncated lead).
Track A2. The case-insensitive comparison family (~600 str_cmp + ~67 strn_cmp call
sites) now folds per character instead of per byte -- without touching a single call
site. Under KOI8-R the original LOWER() byte loops are left verbatim and a guarded
early return sends the UTF-8 build to the code-point fold, so the default build is
byte-identical (28 added lines, 0 removed in utils_string.cpp).

- native_text: add compare_ci / ncompare_ci (KOI8-R: byte-wise via a_lcc_table,
  preserving the magnitude callers propagate; UTF-8: code-point fold via utf8::).
- native_text no longer includes utils.h -- it declares the two case tables directly,
  which also keeps the module standalone-testable.
- tests: ASCII ordering/prefix/budget cases, Cyrillic case-insensitivity, and a
  reference test pinning the KOI8-R branch against the original LOWER() byte loop.
Track A2. isname() (~157 call sites) drives a backtracking state machine over bytes:
classification, the case-insensitive match and every advance assumed 1 byte == 1
character. The structure -- including each `curstr = laststr` backtrack -- is kept
verbatim; only those three operations now go through native_text.

Under KOI8-R this is an identity (is_alnum_char == a_isalnum, chars_equal_ci ==
LOWER == LOWER, char_bytes == 1); a differential harness comparing the old and new
implementations over ASCII and Cyrillic inputs reports 0 differences.

Under UTF-8 it fixes the byte logic in both directions, also verified differentially:
  * false positive: "shchit" matched the unrelated name list "mech dlinnyi",
    because only the shared D0 lead byte was ever compared;
  * false negative: "MECH" no longer matched "mech", because the lead bytes fold
    equal but the trail bytes do not.

- native_text: add is_alnum_char() and chars_equal_ci().
- tests: cases for both primitives, incl. the Cyrillic case-fold regression.
…3681)

Track A2, continued: the command-argument splitters and the Russian name declension
no longer assume 1 byte == 1 character. Both are identities under KOI8-R (verified
differentially, 0 differences) and fix real breakage under UTF-8.

Splitters -- one_argument/any_one_arg/half_chop (mud_string.cpp) and one_word
(utils_string.cpp) lowercased byte by byte, which under UTF-8 leaves Cyrillic
untouched (the byte table maps neither the lead nor the trail bytes), so Russian
command arguments would stop being folded and command lookup would miss. They now
fold one whole character per step. The a_isspace()/quote tests stay byte-based on
purpose: they only run at a character boundary and every delimiter is ASCII.

GetCase (genchar.cpp) picked the declension from name[len - 1] / name[len - 2] and
cut the stem with substr(0, len - 1) -- all byte offsets. Under UTF-8 the last-letter
test never matched, so names simply stopped declining ("Anya" stayed "Anya" in every
case); a differential run over 10 names x 2 genders x 6 cases shows 0/120 differences
under KOI8-R and 65/120 corrections under UTF-8.

- native_text: add copy_lower_char(), last_char_offset() and list_contains_char()
  (the strchr-over-a-letter-list replacement), plus a bounded char_bytes_at() so the
  view-based helpers are safe on a non-null-terminated string_view.
- tests: cases for each new primitive, incl. in-place folding and partial-sequence
  rejection in list_contains_char.
…3681)

Adds tests/text_semantics.cpp: behavioural coverage for the routines the migration
touched -- isname, str_cmp/strn_cmp, one_argument/half_chop and GetCase. Several of
these had no unit tests at all, so this also pays down existing debt rather than only
guarding the new code.

The Russian literals are written as literals, not byte escapes: the file compiles in
whatever encoding the engine is built with, and the routines under test work in that
same native encoding, so the expectations hold both before and after the flip. That
makes this file the guard that flipping the encoding does not change behaviour.

Covered regressions (each one silently broken by byte semantics under UTF-8):
  * isname matched unrelated Cyrillic words that shared a lead byte, and lost
    case-insensitive matching for Russian keywords;
  * argument splitting left Russian arguments unfolded, so command lookup missed;
  * GetCase stopped declining names entirely.

Also renames the fixture in tests/native_text.cpp (kPrivet -> kNtPrivet): the test
sources are unity-built, so it collided with the one in tests/utf8.cpp.
The byte ctype tables break under UTF-8 in one specific shape: a multibyte letter
reads as "alnum lead byte + non-alnum trail bytes", so every loop that scans a *run*
of letters stops in the middle of one. Audited all 82 a_is* call sites; only the
alpha/alnum/upper family can misclassify (isspace/isdigit/isxdigit are ASCII-only and
answer correctly for a trail byte), which narrowed the work to five real scanners:

  * fname()            -- first keyword of a name list
  * im.cpp             -- crafting alias extraction
  * dg_scripts.cpp     -- script token boundaries
  * cut_one_word()     -- word splitting
  * do_gen_comm.cpp    -- the anti-caps filter, which could not see uppercase Cyrillic
                          at all (a UTF-8 lead byte is outside the table's range), and
                          whose percentage denominator counted bytes, not characters

Deliberately left byte-based: IsValidShopId (ids are ASCII identifiers by design) and
the single-character checks in login.cpp / interpreter.cpp, which run at a character
boundary. pred_separator is dead code -- no users outside utils.h -- so it was not
migrated; it should simply be deleted.

Debt paid while here: fname() and the im.cpp alias loop copied into fixed buffers
(30 and 16 bytes) with no bounds check at all -- multibyte text reaches the end twice
as fast, so both now stop short of the buffer. dg_scripts passed a raw (negative) char
to isspace(), which is undefined behaviour.

- native_text: add is_alpha_char() and is_upper_char().
- tests: predicate coverage plus behavioural tests for fname() (incl. the overflow
  guard) and cut_one_word(), neither of which had any before.

Verified: full suite 648 passed / 0 failed; live boot on the production world
(world.20260802.tgz) with declension prompts still correct.
Track A3: a printf "%-Ns" field pads by bytes, so a column of Russian text comes out
half as wide once the text is multibyte. Adds pad_right()/pad_left() (width in
characters -- byte-identical under KOI8-R) and converts the most visible player-facing
columns: WHO short list, WHERE, skills and affects.

Also makes the libfort wrapper pick its base class by encoding: char_table measures a
cell in bytes, utf8_table in code points. Neither is right for both -- under KOI8-R the
byte table is correct and utf8_table would misread KOI8-R as UTF-8 -- so the choice now
follows INTERNAL_ENCODING_UTF8 instead of being hardcoded.

Scope finding: fmt::format's "{:<20}" needs NO migration. Measured against the vendored
fmt -- it counts code points for valid UTF-8 and falls back to one-unit-per-byte for
KOI8-R (which is invalid UTF-8), so it is already correct in both encodings. That takes
every fmt-based column, including where_format, out of this track; only printf-style
fields remain.

Deliberately unchanged: colour codes embedded in a padded string still count toward the
width, exactly as with "%-Ns" today. That skew is pre-existing and orthogonal.
…#3681)

Continues A3 with the player-facing subsystems: score (the grouping line, which also
capped its text with substr(0, 76) -- a byte cut), spellbook, features, exits, PK list,
crafting recipes and item creation, glory stats, parcels and named items.

Adds native_text::truncate_to_chars() for the "cap a display string" case, so a cap
never lands inside a character.

Skipped after checking: do_levels pads only digit strings (thousands_sep), so its
"%13s" fields are ASCII-only and need nothing.

Verified: full suite 649 passed / 0 failed, clean build, and a live boot on the
production world with both a UTF-8 and a legacy KOI8-R client rendering correctly.
…#3681)

Finishes A3 with the immortal-facing listings: show (rune spells, linkdrop, snoop),
last, users, tabulate, liblist, commands and alias, plus the trigger listing in
dg_scripts. The substr(0, N) display caps in show and liblist now go through
truncate_to_chars so they cannot split a character.

do_users shares one format string across three call sites; it becomes plain "%s"
fields with each column padded explicitly.

Checked and deliberately skipped:
  * fmt::sprintf (do_stat) -- measured like fmt::format, already correct in both
    encodings, so the "%-21s" there needs nothing;
  * "%-s" in exchange -- a left-align flag with no width, so it never pads;
  * show_fields/set_fields command names and do_levels' thousands_sep columns --
    ASCII-only content;
  * do_toggle's "%-3s" -- the values are all the same width, so nothing shifts.

Verified: full suite 649 passed / 0 failed, clean build.
CONTRIBUTING.md claimed C++17 while the build has used C++20 for a while
(meson.build: cpp_std=c++20, and -std=c++20 in compile_commands.json).
CLAUDE.md already said C++20; this brings the two in line.
…lper (#3681)

Replaces all 63 native_text::pad_right/pad_left/truncate_to_chars call sites with
fmt, which puts the width back where it belongs -- inside the format string --
instead of wrapping every argument:

    sprintf(buf, "%s - %s\r\n", native_text::pad_right(GET_NAME(i), 20).c_str(), room);
    SendMsgToChar(buf, ch);
  becomes
    SendMsgToChar(fmt::format("{:<20} - {}\r\n", GET_NAME(i), room), ch);

This is possible because fmt measures width AND precision in code points for valid
UTF-8 and falls back to one-unit-per-byte for KOI8-R (which is not valid UTF-8) --
measured, not assumed -- so it is already correct in both encodings and needs no
change at the flip. printf cannot do this: its width/precision are byte-based by
definition, which is what made the helper necessary in the first place.

pad_right/pad_left/truncate_to_chars are removed from native_text along with their
tests; the character primitives the scanners need (char_bytes, is_alnum_char, ...)
stay. Several sites also lose their intermediate char buffer entirely.

Drive-by, on a line this change already touches: do_show's mob listing had
"{:<31}s" in its format string -- a leftover 's' from an earlier %-31s conversion
that printed a stray letter after the name. Removed.

Verified: clean build, full suite 648 passed / 0 failed.
…missed (#3681)

A full audit sweep showed track A was closed prematurely: beyond the hotspots already
converted, 55 more per-byte case sites were left, and they are the same class of bug.

  * 36 "capitalise the first letter" sites (name[0] = UPPER(name[0]) and friends across
    19 files) now go through native_text::capitalize_first, which folds a whole
    character instead of mangling a multibyte lead byte.
  * The shared string helpers are fixed at the source rather than per call site:
    ConvertToLow (both overloads), SubstToLow, SubstStrToLow, SubstStrToUpper, colorLOW,
    colorCAP and IsAbbr. Fixing ConvertToLow alone covers its many callers.

Adds the API the call sites were missing, so they stop doing pointer arithmetic:

  * native_text::chars(s) -- range-for over characters, each element a string_view
    covering exactly one character:
        for (auto letter : native_text::chars(argument)) { ... }
  * native_text::to_lower/to_upper for std::string and char* -- whole-string case
    conversion, which collapses several hand-rolled loops to one line.
  * copy_upper_char, the counterpart of copy_lower_char.

Note on naming: character count stays an explicit call (char_count) rather than a
"length()", because both lengths are genuinely needed -- characters for display width,
bytes for buffer sizing -- and conflating them is how this whole bug class started.

Left as-is deliberately: SubstStrToLow raises case despite its name. That mismatch
predates this work; only its byte semantics changed.

Verified: clean build, full suite 648 passed / 0 failed.
Benchmarking the character-based case conversion showed it was ~17x slower than the
byte loop it replaces under UTF-8. That was not inherent to per-character work; it was
this implementation:

  * a std::string was constructed per character just to hold the re-encoded result;
  * the fold was reached through a function pointer, so nothing inlined;
  * char_bytes() called utf8::sequence_length() across a translation unit, once per
    character, in every scan.

Fixed by folding ASCII and the two-byte Cyrillic block directly on the bytes, with the
general decode/encode path kept only as a fallback for characters this codebase never
carries. Whole-string conversion is now one tight loop with no calls in the hot path.

Measured against a like-for-like (also out-of-line) copy of the old ConvertToLow:
  KOI8-R:  32 ms -> 33 ms   (the production path today: no regression)
  UTF-8 : 140 ms -> 211 ms  (1.5x, and the 140 ms baseline does not even fold Cyrillic)

The benchmark also caught a real bug this work had introduced: the shared fold loop
treated 0xD0/0xD1 as UTF-8 lead bytes, but under KOI8-R those are the letters "p" and
"r" -- it would have corrupted text on the current build. The loops are now per
encoding. Verified exhaustively: under KOI8-R the result is identical to the raw table
for all 255 byte values; under UTF-8 the folds are correct including Yo/yo.

Also adds utf8::encode(char32_t, char*), an allocation-free encode into a caller buffer.
Menus and OLC editors dispatch on a Cyrillic character literal (231 case labels across
19 files). Those cannot survive the flip: with UTF-8 sources a Cyrillic 'x' is a
multi-character constant, so the compiler folds it to an implementation-defined value
and the menu silently stops responding -- and no single literal spelling works for both
encodings.

Rather than give up the switch (option A, if/else chains) or bundle 231 rewrites into
the flip commit (option B), the letters become numeric constants defined per encoding:

    switch (native_text::first_char_code(arg)) {
        case 'y': case 'Y': case rus::kDe: case rus::kDeUpper:

  * src/utils/russian_keys.h -- all 33 letters, upper and lower, as byte values under
    KOI8-R and code points under UTF-8. Both tables were generated and checked against
    the actual codecs rather than written from memory: 0 mismatches in 66 entries.
  * native_text::first_char_code() -- the raw byte under KOI8-R, the code point under
    UTF-8, so the switch expression means the same thing in both.
  * ASCII cases stay ordinary literals; they are identical in either encoding.

This lands now as a no-op (under KOI8-R the constant is exactly the byte the old literal
compiled to) and leaves the flip a one-flag change. At cleanup the KOI8-R half of the
table goes away and the constants can collapse into plain U'...' literals.

Includes the first converted switch (medit's save prompt) as a worked example, and a
test pinning every constant against first_char_code -- verified in both branches, since
a drift here would disable a menu key without any visible error.

Remaining: ~229 labels in 18 files, mechanical from here.
…nts (#3681)

Completes the A4 sweep: 227 case labels across 15 files plus the remaining single-letter
comparisons now dispatch on native_text::first_char_code() against the rus:: constants,
so menus and OLC editors keep working after the flip instead of silently going deaf.

  * 29 switch expressions rewritten: *arg / *argument -> first_char_code(),
    LOWER(...) -> first_char_code_lower(), UPPER(...) -> first_char_code_upper().
    The script refused to touch any switch shape it did not recognise, so nothing was
    converted blind; do_who's `switch (mode)` was handled through its initialiser.
  * Comparison sites in named_stuff, modify and login converted the same way.
  * iosystem's legacy zMUD 'z' -> Cyrillic substitution now writes the letter from a
    *string* literal: string literals are byte-transparent, so the same code is correct
    whether the letter is one byte (KOI8-R) or two (UTF-8). A character literal cannot
    be, which is the whole point of this step.

Left untouched on purpose: six occurrences inside comments (prose, not code).

One site is deliberately NOT converted -- db.cpp's get_filename(). It transliterates a
player name into the save-file name byte by byte, so under UTF-8 it would produce a
different filename and every existing player's files would stop being found. That is a
data-loss risk, not a mechanical edit: it needs character-wise transliteration plus a
test pinning the filename across the flip. Recorded in the issue as C1a.

Verified: clean build, full suite 651 passed / 0 failed.
get_filename() derives a player's save-file name by transliterating the character name
byte by byte. Under UTF-8 a Russian letter is two bytes, so the loop would have produced
a different name and every existing player's files -- saves, aliases, depots, all of
which go through this function -- would have stopped being found. This is the one place
in the migration where a mistake costs characters, so it is fixed with the mapping
pinned rather than re-derived.

  * The mapping was extracted from the actual tables (AltToLat + the lowercase table)
    rather than written from memory, so it reproduces today's output exactly: a->a,
    zh->1, ya->q, yo->9, and upper/lower collapse together because the original
    lowercased after transliterating.
  * native_text::translit_to_filename() implements it in both encodings -- byte-wise
    under KOI8-R (unchanged behaviour), code-point-wise under UTF-8.
  * A test pins all 33 letters in both cases plus a whole name, and it is the kind of
    test that must fail loudly: a silent drift here orphans player files.

Verified in both branches: the full alphabet transliterates to the identical string
("abvgde91zijklmnoprstyfhc74683250q") and "Vasya" in Cyrillic gives "vasq" either way.

Writing the check also caught a bug the normal build cannot see: the UTF-8 branch used
char_bytes_at() before its declaration, so that branch did not compile at all -- it
would only have surfaced at the flip. It now takes the length from utf8::decode().

Full suite 652 passed / 0 failed.
The UTF-8 branch of native_text had no CI coverage at all -- nothing built it, which is
how a branch that did not even compile survived until a hand-written check found it. A
unit test cannot close that gap on its own: it only ever exercises the branch its build
selected.

Adds a Linux job that builds the *coherent* UTF-8 configuration. Sources and runtime
flip together, never separately: git already stores src/ and tests/ as UTF-8, so
dropping working-tree-encoding and checking out again yields UTF-8 sources, and
-Dinternal_encoding=utf8 switches the runtime to character semantics. That is exactly
what C2 will do, so the job is a continuous rehearsal of the flip rather than a synthetic
configuration.

It paid for itself immediately -- two real flip blockers, neither of which any existing
test or the audit script could see:

  * utils.cpp sized the Russian month names as char[12][10]. That fits KOI8-R ("Sentyabrya"
    is 8 bytes) and overflows in UTF-8 (16). Now an array of pointers, so the cell width
    stops depending on the encoding at all.
  * where_format indented continuation lines by prefix.size() -- bytes, not columns -- so
    the location column drifted apart for names with Cyrillic in them. The test that was
    supposed to catch this measured alignment in bytes too, so it agreed with the bug;
    both now measure characters.

Verified in both configurations: 652 passed / 0 failed, no warnings.

Incidentally confirms the literal-repertoire rule this migration imposes: a comment
written with guillemets could not be encoded back to KOI8-R, and git refused it.
Adds native_text::from_koi8() -- identity under KOI8-R, a transcode under UTF-8 -- and
routes the YAML loader's GetText() through it. That is the single point all world text
passes, so the loader stops being encoding-specific and the world files can stay KOI8-R
on disk (track B remains deferred).

No-op on the current build; 652 passed / 0 failed.

Booting the flip build on the production world surfaced two things worth recording now
rather than on the day of the flip:

  * The server aborted during boot in HelpSystem::SetsHelp -> libfort, on data from an
    XML config. So converting the world loader is not sufficient: every KOI8-R source
    (cfg/**, help text, boards, mail, saves) needs the same boundary before C2.
  * libfort's utf8_table does not degrade on malformed input -- the visible-width
    calculation underflows and the allocation check aborts the process. An unconverted
    string reaching any table is therefore a crash, not a cosmetic defect, which raises
    C3 from "convert what players see" to "convert every source".

Neither is reachable from unit tests; both came out of running the real binary against
the real world, which is what the rehearsal job is for.
…(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.
kvirund and others added 30 commits August 20, 2026 16:01
В прошлом коммите я завёл starts_with, не заметив, что в проекте уже есть
utils::IsAbbr(abbr, text) -- ровно эта проверка, посимвольная, и её зовут из
39 файлов. Второе имя для того же понятия хуже исходной болячки, поэтому свой
хелпер убран, все 54 вызова переведены на IsAbbr.

Семантика та же: IsAbbr(P, X) -- это "X начинается с P". Порядок аргументов
обратный, полярность сохранена.
…3681)

Тридцать три вызова strn_cmp были той же проверкой "одно есть префикс другого",
только длину для неё считали руками: strlen(arg), заведённая рядом переменная i,
agr_length, len, l. Это ровно utils::IsAbbr, и она посимвольная.

Заодно ушли семь переменных, существовавших только ради этой длины, вместе с
приёмом "l = 1, чтобы пустая строка не совпала с первой попавшейся": IsAbbr на
пустом префиксе и так возвращает false.

Отдельно поправлено сравнение падежа в login.cpp: там первые kMinNameLength
байт сверялись с именем, а под UTF-8 пятёрка -- это две с половиной русские
буквы. Теперь отсчёт по символам.

От strn_cmp осталось два вызова, и оба -- не префикс, а минимум двух длин:
сверка падежа (login.cpp) и поиск по таблице игроков (player_index.cpp).
…имволах (#3681)

Оба оставшихся вызова strn_cmp оказались одной задачей -- «совпадают ли первые
N символов», -- и в обоих N приходил из kMinNameLength, то есть означал буквы,
а работал как байты. Под UTF-8 пятёрка -- это две с половиной русские буквы:
сверка падежа в login и поиск похожего имени в таблице игроков сравнивали вдвое
меньше, чем задумано, и потому срабатывали слишком охотно.

utils::IsSamePrefix(a, b, chars) считает символы. Если в какой-то из строк их
меньше -- не совпадение, как и раньше.

После этого strn_cmp остался без вызовов и удалён, а с ним и native_text::
ncompare_ci -- она существовала только под него и несла тот самый байтовый
бюджет. Теперь длину в байтах не задаёт ни одна строковая функция проекта:
префиксы -- IsAbbr, первые N символов -- IsSamePrefix, целиком -- str_cmp.

Тест на байтовый бюджет заменён тестом на символьный.
Пароль. Хэш обязан считаться по дисковой форме -- на расхождении с байтами
в памяти и отвалился вход у всех, чей пароль с кириллицей. Соль случайная,
поэтому свойство проверяется через следствие: длинное тире и дефис на диске
неразличимы (в KOI8-R тире нет), значит пароли с ними обязаны совпасть.
Без фикса этот тест падает, остальные три -- страховка на будущее.

Пометка зон. Конвертер обязан отвечать "зона изменилась" только когда ключ
действительно выставлен: у предмета с нулевыми values писать нечего, и старое
безусловное return 1 метило зону на каждом буте. Три из четырёх тестов падают
на старом поведении.

Первая версия теста про сосуды оказалась пустой: set_val у жидкостной тары
сразу пишет ключи, guard срабатывает, и до засева дело не доходит. Переписан
так, чтобы воспроизводить загрузку мимо set_val -- теперь падает как надо.

Симметрия границ. Что прочитано через from_disk_text, обязано уйти обратно
теми же байтами: нарушение этой пары и съело метки вещей, сундуки и списки имён.
…3681)

Фикс пароля был асимметричным: сравнение приводило пароль к дисковой кодировке,
а generate_md5_hash под NOCRYPT возвращал нативную строку как есть. То есть в
сборке без crypt() кириллический пароль не сходился сам с собой -- ровно та же
болячка, от которой фикс и защищал, только в другой ветке #ifdef.

Собрано это не на экзотике: crypt() не находится на Windows и macOS, и meson
сам включает -DNOCRYPT (meson.build:205). Отсюда и красные Run tests в CI на
четырёх джобах при зелёном линуксе -- новый тест поймал настоящий баг, а не
разошёлся с платформой.

Воспроизведено локально сборкой с -Dnocrypt=true: до правки падают два теста
из четырёх, после -- вся сюита зелёная в обеих конфигурациях.
На macOS crypt() -- классический DES: он смотрит только первые восемь символов
пароля. Пара 'parolparol' и 'parolparo1' различается десятым, то есть для DES
это один и тот же пароль, и проверка «неверный отвергнут» на macOS превращалась
в свою противоположность. Windows и Linux этого не показывали: там либо NOCRYPT,
либо MD5-crypt без такого усечения.

Различаем первой буквой. Остальные три теста под DES безопасны: их пароли
короче восьми байт в дисковой форме и различаются в самом начале.
В названии ёмкости после переливания оставался лишний пробел:
"древний череп  с синим колдовским зельем".

name_from_drinkcon срезал разделитель " с " жёсткой тройкой байт --
столько он занимает в KOI8-R. В UTF-8 "с" двухбайтовая, разделитель
занимает четыре байта, срезалось три, и первый пробел оставался в
названии. При следующей заливке к нему снова клеилось " с " -- пробелов
становилось два. То же самое во втором месте, в цикле по падежам.

Разделитель вынесен в константу kLiquidSeparator, длина берётся из неё
же, и сборка названия (name_to_drinkcon) пользуется той же константой --
разбор и сборка больше не могут разъехаться. Добавлена защита от
pos < длины разделителя: иначе substr на коротком имени уходит в
underflow size_t.

Проверка: g++ -fsyntax-only по заголовкам ветки проходит.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Закрывает #3751. CalcGeneralSaving собирал диагностическую строку через
sprintf в буфер на 256 байт. Сама фраза -- сто с лишним русских
символов, плюс имя жертвы и название спаса: в KOI8-R это укладывалось
впритык, в UTF-8 те же символы занимают вдвое больше, и на длинном имени
("могучий привратник восточных ворот") fortify ловил переполнение и
валил процесс.

Строка теперь собирается через fmt::format в std::string -- границы у
неё нет. Заодно она строится только когда её кто-то увидит:
spell_trace::Active для того и выставлен наружу, а CalcGeneralSaving --
горячий путь, до сих пор он форматировал текст на каждый спас-бросок,
даже если в игре нет ни одного тестера.

Обе ветки сообщения (критудача ДА/НЕТ) слиты в одну: отличались они
только цветом, словом и делением save пополам, которое вынесено выше.

Проверка: g++ -fsyntax-only по заголовкам ветки проходит.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Прочёсывание ветки на предмет буферов фиксированного размера с русским
текстом (после #3751) нашло ещё одно место с реальным переполнением:

    char message[100];
    sprintf(message, "неизвестный прототип объекта : %s (VNUM=%d)", имя, внум);

Сам текст в UTF-8 занимает 69 байт, на имя предмета остаётся 31 -- то
есть пятнадцать русских букв. "курганный оберег" уже не влезает, а
sprintf через fortify роняет процесс, как в #3751.

Строка собирается через fmt::format, буфер не нужен: у mudlog есть
перегрузка под std::string.

Проверка: g++ -fsyntax-only по заголовкам ветки проходит.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Закрывает #3752. show_obj_to_char собирал описание наполнения ёмкости
через промежуточный буфер:

    char tmp2[128];
    sprintf(tmp2, "(%s)", tmp);

tmp -- целая фраза: "наполнен меньше, чем на четверть черной вязкой
*отравленной* жидкостью". В KOI8-R это 70 байт и в 128 укладывалось с
запасом, в UTF-8 те же символы дают больше 128, и fortify валил процесс
на обычном "осмотреть суму".

Буфер убран, строка собирается через fmt::format сразу в buf2.

Заодно прогнал по ветке поиск того же класса: мелкий буфер, в который
sprintf кладёт текст, приходящий из функции (а не литералом -- такие
литералы я проверял в прошлый раз). Нашлось восемь мест, все с большим
запасом: короткая латинская обвязка плюс имя.

Проверка: сборка с -D_FORTIFY_SOURCE=3 чистая, 658 тестов проходят.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Crash_report_rent_item собирал строку постоя в static char buf[256], а в
неё входит название предмета в винительном падеже -- часть переменной
длины, которую никто не ограничивает.

Запас под UTF-8 съедается почти весь. Самое длинное название прототипа в
мире -- 144 байта, строка с ним выходит около 217 байт из 256. Но имена
скрафченного собираются в рантайме и бывают длиннее ("плащ из крепкой
шкуры жуткого златобрюха с шелковой нитью и золотой цепочкой" -- 141
байт сам по себе), плюс к названию клеится пользовательская метка. В
KOI8-R та же строка занимала вдвое меньше и вопроса не возникало.

Переполнения на реальных данных я не воспроизвел -- показываю запас, а
не факт. Но место ровно того же класса, что #3751 и #3752, и лечится так
же: строка собирается через fmt::format в std::string, буферы buf[256],
bf[80] и bf2[14] не нужны вовсе. Формат сообщения не изменился.

Заодно ушла асимметрия в вычислении цены: она считалась дважды одним и
тем же выражением (для числа и для названия валюты) -- теперь один раз в
переменную, поведение то же.

Проверка: сборка с -D_FORTIFY_SOURCE=3 чистая, 658 тестов проходят.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ffers

script_log и trig_log держали по char tmpbuf[kMaxStringLength] -- это 32
килобайта на стеке каждый, а trig_log зовёт script_log, так что запись
одной строки в лог триггера съедала 64 КБ стека.

Переполнения там не было (обе записи шли через snprintf с правильным
размером), но буферы не нужны: строка собирается fmt::format, у mudlog
есть перегрузка под std::string.

Побайтная замена маркеров UID сохранена как есть -- таблица трогает
только 0x1C, 0x1D и 0x1E, эти коды меньше 0x80 и внутри многобайтовых
UTF-8 последовательностей не встречаются, так что кириллица через цикл
проходит нетронутой. Добавил про это комментарий, чтобы цикл не выглядел
опасным при следующем чтении.

Проверка: сборка с -D_FORTIFY_SOURCE=3 чистая, 658 тестов проходят.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…R_ALL

Замена меток UID перед записью в лог делалась таблицей на 256 значений,
из которых 253 отображали байт сам в себя. Из кода не было видно, что
именно происходит, и таблицу забыли обновить, когда появился
UID_CHAR_ALL ('\x1f') -- он уезжал в лог сырым управляющим байтом.

Теперь std::replace_if по четырём меткам: OBJ, ROOM, CHAR и CHAR_ALL.
Таблица (35 строк) удалена, поведение для трёх старых меток прежнее,
четвёртая наконец обрабатывается.

Проверка: сборка с -D_FORTIFY_SOURCE=3 чистая, 658 тестов проходят.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
мерж из мастера
…названия ёмкости

Переменная. size_t i = strlen(name) в forget_recipe осталась от перевода на
IsAbbr -- единственное предупреждение во всей сборке, и моё. Когда я чистил
такие остатки, я считал вхождения имени по всему файлу, а надо было в пределах
функции: 'i' там встречается сто раз, но в этой функции больше не нужна.

Тест. Заливка и опустошение ёмкости обязаны возвращать название байт в байт.
Разделитель ' с ' срезался жёсткой тройкой байт -- столько он занимает в KOI8-R,
а в UTF-8 их четыре, -- и в названии оставался лишний пробел, который при
следующей заливке удваивался. На старом поведении оба теста падают.

Сборка с нуля: ноль предупреждений, ноль ошибок, 657 тестов проходят.
Конфликт один и тривиальный -- блок включений в src/engine/db/db.cpp:
ветка добавила <sstream>, мастер <fstream> и malloc.h под guard'ом для
malloc_trim. Оставлены оба.

Сборка ветки с -D_FORTIFY_SOURCE=3 чистая, 660 тестов проходят.
Поиск богом по частому слову ('где белый кам') отвечал '***ПЕРЕПОЛНЕНИЕ***'
вместо списка. Буфер вывода дескриптора ограничен байтами -- kLargeBufSize,
48864, -- а под UTF-8 русский текст занимает вдвое больше, чем занимал в
KOI8-R. То есть вместимость в буквах упала вдвое, и списки, которые раньше
влезали, перестали.

Ограничения на объём у команды нет: PerformImmortWhere собирает все совпадения
по миру и шлёт одним куском через SendMsgToChar. Остальные длинные списки в
проекте так не делают -- склад, обменник, 'кто' и осмотр давно выводят через
page_string, который режет текст по высоте экрана игрока. Переведён и 'где',
оба его списка: найденное по слову и перечень игроков без аргумента.

Буфер не трогаю: kMaxSockBuf задаёт заодно размер стековых буферов в
process_output (i[96K] и o[288K]), и поднимать его ради одной команды -- это
384 КБ стека превратить в 768.

page_string при отсутствующем дескрипторе просто выходит, так что для
персонажа без связи поведение прежнее.
Новая диагностика переполнения печатает t->last_input. Эта запись заполняется
в process_input на ЛЮБОЙ строке от клиента -- проверки состояния над ней нет, --
поэтому в парольных состояниях там лежит сам пароль в открытом виде.

Состояний семь: ввод пароля при входе, задание и подтверждение нового, три шага
смены пароля и подтверждение удаления персонажа. Во всех вместо ввода печатается
'<ввод скрыт>'.

Достижимо это или нет -- вопрос спорный: переполнение требует около 47 КБ
невыведенного текста, а сразу после верного пароля сервер как раз шлёт MOTD,
новости и уведомления о сообщениях. Но пароль открытым текстом в сислоге --
не та вещь, которую стоит оценивать по вероятности.
синхрон с мастером
Экран справки ("справка" без параметров) приезжал игроку кашей.

ReadFileToBuffer читал файл сырыми байтами и отдавал как есть. На диске
тексты в KOI8-R, движок держит их в нативной кодировке, и на выходе
перекодировщик получал KOI8 вместо UTF-8. Темы справки этой болезнью не
страдали -- load_help переводит кодировку сам, -- а экран грузится другим
путём, через AllocateBufferForFile, и перевода там не было.

Собираем текст в строку и переводим один раз через from_disk_text.
Проверка на размер осталась, но теперь считает уже переведённый текст:
под UTF-8 он длиннее прочитанного с диска.

Тем же путём читается всё, что грузится через AllocateBufferForFile --
экран справки при старте и при "перезагрузить справку".

Проверка: сборка ветки с -D_FORTIFY_SOURCE=3 чистая, 660 тестов
проходят.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Команда "синоним" показывала русские синонимы кашей: alias.cpp читал и
писал файлы сырыми байтами. Файлы лежат на диске в кодировке мира,
движок держит текст в нативной, перекодировки на границе не было вовсе.

Особенность формата: перед каждой строкой пишется её длина в байтах.
Поэтому просто перекодировать при выводе нельзя -- при записи длина
считается уже по перекодированной строке, иначе чтение разъедется на
первом же русском синониме.

На чтении взят from_disk_line, а не from_koi8: он распознаёт уже нативный
текст и не перекодирует повторно. Это важно прямо сейчас -- файлы,
которые сборка без перекодировки успела записать в UTF-8, прочитаются как
есть, а при следующей записи лягут на диск уже правильно.

Проверка: сборка ветки с -D_FORTIFY_SOURCE=3 чистая, 660 тестов
проходят.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Конфликт один -- do_last.cpp. В ветке вывод переведён на fmt::format, в
мастере (#3768) в том же месте поменялась заглушка адреса на "НеВедется".
Взят вариант ветки с заглушкой из мастера.

Сборка ветки с -D_FORTIFY_SOURCE=3 чистая, 660 тестов проходят.
Правка разделителя (9c9a67f) чинила только новые названия. Старая сборка
резала " с " по трем байтам вместо четырех и оставляла в названии хвостовой
пробел -- такие названия уже разъехались по файлам вещей. Дальше круг замкнут:
при загрузке name_from_drinkcon снимает ровно разделитель и получает "огромная
дубовая бочка ", а name_to_drinkcon приклеивает разделитель обратно -- и снова
"огромная дубовая бочка  с черным колдовским зельем", при каждой загрузке.

Срезаем хвостовые пробелы с обеих сторон: и когда название жидкости снимаем, и
когда добавляем. Загрузка вещи гоняет эту пару подряд, поэтому испорченные
названия чинятся сами, а описки билдеров в прототипах не всплывают в игре.

Заодно в name_to_drinkcon ушли sprintf в char[] (теперь fmt::format) и чтение
drinknames[] по отрицательному индексу.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUwDWDnYdXrJdvjDVd36QH
diag_obj_to_char сама возвращает строку с ведущим пробелом, а формат вывода
добавлял ещё один: предмет показывался с двумя пробелами перед состоянием --
"огромная дубовая бочка  <великолепно>". Тот же лишний пробел вылезал и справа,
перед всем, что дописывается следом: "сундук <хорошо>  (есть содержимое)".

Пробел ставит теперь только сама diag_obj_to_char, а описание наполнения
ёмкости приносит свой.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUwDWDnYdXrJdvjDVd36QH
апдейт из мастера
Строка ёмкости в списке разрасталась вдвое:

  [ 2197] огромная дубовая бочка с черным колдовским зельем <великолепно>
  (наполнена меньше, чем на четверть черной вязкой жидкостью)

Название и так содержит жидкость, а фраза про наполнение занимает полстроки. В
списке от неё остаётся только пометка "(пусто)" -- то, чего по названию не
видно. Полное описание никуда не делось: "осмотреть <ёмкость>" по-прежнему
печатает и наполнение, и качество жидкости.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUwDWDnYdXrJdvjDVd36QH
feat(sight): shorten the drinkcon line in item lists
Конфликт один, в command_wtrigger: мастер поменял поведение для спящего игрока
(#3783 -- триггер не запускается, но команду у игрока не отбираем, continue
вместо сообщения и return 1), а в ветке та же строка переведена со strn_cmp на
utils::IsAbbr. Взято поведение мастера с вызовом из ветки.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants