Skip to content

feat(utf8): граница кодировки на загрузке данных (C3a, #3681) - #3690

Closed
kvirund wants to merge 27 commits into
kvirund/utf8-migration-plan-16b188from
kvirund/utf8-c3-encoding-boundary
Closed

feat(utf8): граница кодировки на загрузке данных (C3a, #3681)#3690
kvirund wants to merge 27 commits into
kvirund/utf8-migration-plan-16b188from
kvirund/utf8-c3-encoding-boundary

Conversation

@kvirund

@kvirund kvirund commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Черновик намеренно. Стекнут на #3682 — база этого PR — ветка трека A, поэтому в diff только работа по C.

Трек C: провести данные и клиентов через границу кодировки. Под текущей сборкой всё — no-op.

Итог: сервер работает под UTF-8 на боевом мире

Контейнер на порту 4000 сейчас собран с INTERNAL_ENCODING=utf8 и крутится на world.20260802.tgz. Проверено вживую:

проверка результат
загрузка боевого мира без SYSERR
вывод: UTF-8 / KOI8-R / Alt / Win-1251 клиенты побайтно идентичен KOI8-R-сборке
вход существующим персонажем работает из всех кодировок клиента
создание нового персонажа работает, склонения верные
индекс игроков 9117 записей — столько же, сколько под KOI8-R
логи уходят в Loki (otel-only), кириллица читается

Найденные и исправленные блокеры

Каждый нашёлся только запуском настоящего бинаря на настоящем мире — юнит-тесты их не видят.

  1. Таблицы перекодировки были строковыми литералами из старших байтов. Под UTF-8-исходниками они разрастались до 214–298 байт вместо 128, и все легаси-клиенты молча получали мусор. Переписаны \xNN-escape'ами, байты сверены.
  2. Побайтный LOWER при построении индекса игроков. У «г» второй байт UTF-8 равен 0xB3, а в KOI8-R это «Ё» → таблица опускала его в 0xA3, и буква становилась «У». Имя файла получалось неверным, персонаж не загружался и не попадал в индекс: 1278 из 9117 персонажей теряли доступ к своим чарам. Найдено подсчётом пропусков в обеих сборках (2 против 1280) и дампом пропущенных имён — свароУ, сиУурд, раУнар.
  3. Побайтный разбор имени при входе — хвостовой байт не проходил a_isalpha, любое русское имя отвергалось.
  4. Побайтный хэш и компаратор индекса — «Дрегвий» и «дрегвий» давали разные хэши.
  5. Двойная конверсия на сетевой границе — UTF-8-клиенту уже-UTF-8 текст прогонялся через koi_to_utf8 второй раз.
  6. 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 ещё не сделано

  • Запись сейвов пока в нативной кодировке без миграции старых файлов на диске (читаются оба варианта).
  • Стратегия замены для символов вне репертуара легаси-клиента (сейчас конвертер ставит +).
  • Доски и почта не проверялись отдельно.
  • Мир на диске остаётся KOI8-R (трек B) — конвертируется на чтении.

kvirund added 27 commits August 5, 2026 06:10
…(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 (из-за неё "имя <персонаж> одобрить" не находило персонажа),
первая буква направления в списке выходов, титул, имена в дружине, раса и вера
в "счет".
@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.

1 participant