Skip to content

feat(utf8): KOI8-R→UTF-8 migration groundwork (#3681) - #3682

Closed
kvirund wants to merge 22 commits into
masterfrom
kvirund/utf8-migration-plan-16b188
Closed

feat(utf8): KOI8-R→UTF-8 migration groundwork (#3681)#3682
kvirund wants to merge 22 commits into
masterfrom
kvirund/utf8-migration-plan-16b188

Conversation

@kvirund

@kvirund kvirund commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Черновик намеренно — не мёрджить, пока не отревьюено. Держим все ранние шаги «Плана Наполеон» в одном PR до первого мержа.

Фундамент миграции KOI8-R → UTF-8 (#3681). Поведение на дефолтной KOI8-R-сборке не меняется — это no-op-рефактор, который «оживёт» только при флипе кодировки (трек C).

A0 — символьный слой + аудит

  • src/utils/utf8.{h,cpp} — encoding-agnostic хелперы по кодовым точкам, без внешних зависимостей (без iconv/ICU), ASCII насквозь: decode/encode, is_valid (строгая проверка по Unicode Table 3-7), length/byte_offset/char_at/substr, регистр ASCII + кириллица (вкл. Ё/ё).
  • tests/utf8.cpp — 13 GTest-кейсов (пустые, неполные последовательности, 4-байтовые, BOM, overlong, суррогаты, границы диапазонов, кириллица).
  • tools/audit_utf8_migration.py — триаж byte-vs-char мест по категориям с приоритетом файлов с русским текстом.

A1 — флаг internal_encoding + первый роутинг

  • meson_options.txt / meson.build — опция internal_encoding (koi8r по умолчанию / utf8-DINTERNAL_ENCODING_UTF8).
  • src/utils/native_text.{h,cpp} — операции в нативной рантайм-кодировке (char_count / capitalize_first / truncate_offset): KOI8-R-ветка байт-в-байт, UTF-8-ветка через utf8::, плюс native_is_utf8().
  • src/utils/utils.cppformat_text() (счёт ширины, заглавная первая буква, обрезка по maxlen) больше не предполагает «1 байт = 1 символ».
  • tests/native_text.cpp — адаптивный набор (ветвится по native_is_utf8()), корректен и под KOI8-R, и под UTF-8 сборку.

Проверка

UTF-8-ветки прогнаны автономно с -DINTERNAL_ENCODING_UTF8 (18/18 зелёные, чисто под -Wall -Wextra); KOI8-R-ветки (дефолт) проверит CI. utils.cpp правился через iconv, кодировка KOI8-R сохранена.

Дальше в этот же PR (до первого мержа)

Остаток A1 — пейджер next_page и string_add; затем A2 (регистр/ctype/сравнение). Каждый — отдельным коммитом.

VERSION.txt0.1.28.

…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().
@kvirund
kvirund marked this pull request as draft August 3, 2026 01:07
@kvirund kvirund changed the title feat(utf8): character-semantic UTF-8 helpers + migration audit (#3681) feat(utf8): KOI8-R→UTF-8 migration groundwork (#3681) Aug 3, 2026
kvirund and others added 10 commits August 3, 2026 04:08
…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.
@bylins
bylins self-requested a review August 3, 2026 16:53
@bylins
bylins marked this pull request as ready for review August 3, 2026 16:53
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.
@kvirund
kvirund marked this pull request as draft August 4, 2026 02:10
kvirund added 5 commits August 4, 2026 04:30
…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.
@bylins
bylins removed their request for review August 4, 2026 03:57
@bylins
bylins marked this pull request as ready for review August 4, 2026 03:57
kvirund added 3 commits August 4, 2026 06:28
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.
Конфликт только в VERSION.txt: master ушёл на 0.1.31, ветка была на 0.1.28.
Разрешено в 0.1.32 -- мерж этой ветки сам по себе является фиче-мержем и по
политике версий требует инкремента патча поверх master.
@kvirund

kvirund commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

Объединено в #3709: одна ветка kvirund/utf8-migration, история линейная, мердж-коммитов нет.

@kvirund kvirund closed this Aug 9, 2026
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