diff --git a/.gitattributes b/.gitattributes index 4a6f9d103d..a7cea65541 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,7 @@ -/src/** working-tree-encoding=KOI8-R eol=lf -/tests/** working-tree-encoding=KOI8-R eol=lf -/tests/**/*.py working-tree-encoding=UTF-8 eol=lf +# Исходники в UTF-8 (issue #3681). До флипа тут стояло working-tree-encoding=KOI8-R: +# в git блобы всегда лежали в UTF-8, а в рабочее дерево выкладывались в KOI8-R. +/src/** eol=lf +/tests/** eol=lf *.md working-tree-encoding=UTF-8 eol=lf /.githooks/** working-tree-encoding=UTF-8 eol=lf /lib.template/** working-tree-encoding=KOI8-R eol=lf diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 5be69d9a14..0482679212 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -5,7 +5,7 @@ # ============================================================================ # Блокирует коммит если: # 1. Найден символ замены U+FFFD (О©╫) -# 2. Файл должен быть KOI8-R, но находится в UTF-8 +# 2. Файл должен быть KOI8-R, но находится в UTF-8 (данные мира; исходники наоборот -- UTF-8) # 3. Найдены типичные паттерны битой кодировки # ============================================================================ @@ -97,8 +97,21 @@ for FILE in $FILES; do fi echo " ✅ Кодировка правильная" + elif [[ "$FILE" == src/* || "$FILE" == tests/* ]]; then + # Исходники после флипа (issue #3681) обязаны быть валидным UTF-8. Файл, случайно + # сохранённый редактором в KOI8-R, внешне выглядит нормально, но ломает литералы. + if ! iconv -f utf-8 -t utf-8 "$FILE" >/dev/null 2>&1; then + echo "📄 $FILE" + echo " ❌ ОШИБКА: не UTF-8. Исходники должны быть в UTF-8." + ERROR_FOUND=1 + continue + fi + if grep -q "$BAD_CHAR" "$FILE" 2>/dev/null; then + echo "❌ [BLOCKER] Символ замены (О©╫) найден в: $FILE" + ERROR_FOUND=1 + fi else - # Для не-KOI8-R файлов проверяем только символ замены + # Для остальных файлов проверяем только символ замены if grep -q "$BAD_CHAR" "$FILE" 2>/dev/null; then echo "❌ [BLOCKER] Символ замены (О©╫) найден в: $FILE" ERROR_FOUND=1 diff --git a/CLAUDE.md b/CLAUDE.md index f9555ec06c..c90219e680 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -213,46 +213,14 @@ Access world state via `MUD::` namespace functions: - Telnet protocol with MSDP (Mud Server Data Protocol) and MCCP compression - Multiple codepage support (Alt, Win, UTF-8, KOI8-R) -## Development Guidelines (from CONTRIBUTING.md) +## Development Guidelines -### Code Style -- **Standard**: C++20 (C++17 minimum) -- **Indentation**: Tabs (size 4), no spaces for indentation -- **Braces**: An opening brace on the same line separated by a space -```cpp -if (condition) { - // code here -} -``` -- **One statement per line** (applies to variable declarations too) -- **Always use braces** for if/else/for/while bodies, even single statements -- **Pointers/References**: `*` and `&` attached to type, space after -```cpp -const char* message = "example"; -const auto& reference = message; -``` - -### File Management -- All new files must be added to `meson.build` (sources go into `main_sources` / `library_sources`, depending on the section) -- Include vim modeline at end of files: -```cpp -// vim: ts=4 sw=4 tw=0 noet syntax=cpp : -``` - -### Memory Management -- Prefer `new`/`delete` over `malloc`/`free` -- Use smart pointers (`CharData::shared_ptr`, `ObjData::shared_ptr`) where possible -- Avoid raw pointers for ownership - -### Testing -- Run unit tests before committing: `./build/tests/tests` -- Write tests for new code using GoogleTest framework -- Tests are in `tests/` directory, named by module (e.g., `char.affects.cpp`, `fight.penalties.cpp`) +**Правила разработки живут в [CONTRIBUTING.md](CONTRIBUTING.md) — читай его перед тем, как +править код, и следуй ему.** Здесь их копии нет намеренно: две копии расходятся, и потом +непонятно, какая верна. -### Compiler Warnings -- Must compile on Windows, Linux, and Cygwin -- Fix all compiler warnings, especially in new code -- Build with `-Wall -Wextra` enabled +Там про стиль (отступы табами, скобки, по одному объявлению в строке), добавление новых файлов +в `meson.build`, работу с памятью, unit-тесты и требование к сборке без предупреждений. ## Common Development Patterns @@ -367,12 +335,12 @@ The heartbeat system includes built-in profiling: ## Critical Notes -- **File Encoding**: All project source files MUST be in KOI8-R encoding (not UTF-8). This is critical for proper Russian text handling in the codebase. Use `iconv` to convert if needed: `iconv -f utf8 -t koi8-r input.cpp > output.cpp` +- **File Encoding**: All project source files are UTF-8 (issue #3681). Edit them directly — no iconv dance. The world data on disk (`lib/`, `lib.template/`) is still KOI8-R; see `.gitattributes` for exactly which trees. - **Thread Safety**: Main game loop is single-threaded; use `BlockingQueue` for cross-thread communication - **Shared Pointers**: Always use `CharData::shared_ptr` and `ObjData::shared_ptr` to prevent use-after-free - **Pulse Timing**: Never use wall-clock delays; register actions with heartbeat system - **Script Depth**: DG Scripts limited to 512 recursion depth to prevent stack overflow -- **Runtime Encoding**: While source files are KOI8-R, runtime text can be multiple encodings (Alt, Win, UTF-8, KOI8-R) based on client settings +- **Runtime Encoding**: The engine holds text in UTF-8 and works on it per character (`utils/native_text.*`). Client codepages (Alt, Win, KOI8-R, UTF-8) are converted at the network boundary only; the disk boundary (`from_disk_text` / `to_disk`) keeps world files in KOI8-R. ## Claude Code Workflow Rules @@ -401,33 +369,40 @@ meson setup build_otel \ ninja -C build_otel -j$(($(nproc)/2)) ``` -### File Encoding - CRITICAL -**Proper workflow for editing KOI8-R files:** +### File Encoding +Sources are UTF-8. Edit `.cpp`/`.h` directly with the Edit tool — the KOI8-R conversion dance is +gone (issue #3681). Git always stored these blobs as UTF-8; what changed is that the working tree +no longer converts them on checkout. -For files marked as `working-tree-encoding=KOI8-R` in .gitattributes (all files in /src/**, /tests/**): +**Переключились на ветку с флипом в уже существующем дереве — перевыкачайте исходники:** ```bash -# 1. Convert to UTF-8 for editing -iconv -f koi8-r -t utf-8 src/file.cpp > /tmp/file_utf8.cpp +rm -rf src tests && git checkout -- src tests +``` + +`working-tree-encoding` применяется во время checkout'а, а содержимое блоба при флипе не менялось, +поэтому git оставляет ранее выкаченные файлы в KOI8-R и считает дерево чистым — `git status` молчит. +Собранный из такого дерева бинарь получает кои-восьмые строковые литералы при движке, который +считает весь текст UTF-8: расходятся сравнения имён, доски, кодировка лога, а запись на диск гонит +такие литералы через словарь транслита. `meson setup` теперь это проверяет и отказывается собирать. -# 2. Edit the UTF-8 version with Edit tool or text editor -# (make your changes here) +Still KOI8-R on disk, and still needing care: the world and configs (`lib/`, `lib.template/`, +`/lib/cfg/**`, `/lib/misc/**`, `/lib/text/help/**`, `/lib/etc/board/**`). For those the old rule +holds — convert, edit, convert back: -# 3. Convert back to KOI8-R -iconv -f utf-8 -t koi8-r /tmp/file_utf8.cpp > src/file.cpp +```bash +iconv -f koi8-r -t utf-8 lib/cfg/some.xml > /tmp/some_utf8.xml +# edit /tmp/some_utf8.xml +iconv -f utf-8 -t koi8-r /tmp/some_utf8.xml > lib/cfg/some.xml ``` -**NEVER use the Edit tool directly on existing .cpp/.h files that contain Russian text.** -Only use Edit for: -- Newly created files that will be pure ASCII/English -- Temporary UTF-8 converted files (in /tmp) -- Files in .gitattributes marked as UTF-8 (e.g., Python files) +The pre-commit hook enforces both directions: sources must parse as UTF-8, world files must stay +in the encoding `.gitattributes` declares. **NEVER use sed for editing source files.** Sed has tendency to: - Modify files in unexpected places (matching wrong lines) - Lead to file corruption detection and accidental `git checkout` (losing all uncommitted work) - Cause cumulative errors from multiple sed operations -- Corrupt KOI8-R encoding **Alternative: unified diff patches** - for small targeted changes: ```bash diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0bba9fd8cf..50d5a7c9bd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ При написании кода вы должны следовать правилу "одна команда - одна строка". Желательно это же правило распространять и на объявления переменных. -Былины используют стандарт C++17. +Былины используют стандарт C++20 (задан в meson.build: cpp_std=c++20). Тела конструкций if, if ... else for, while, do ... while всегда должны быть заключены в символы '{', '}'. @@ -22,10 +22,27 @@ using meat_mapping_t = std::pair; -Код должен быть компилируемым на Windows, Linux. При компиляции вы должны -уделять особое внимание предупреждениям компилятора и исправлять их при первой -же возможности. Особенно если предупреждения появились вследствие добавленного -вами кода. +Код должен быть компилируемым на Windows, Linux. + +**Перед коммитом сборка должна быть без единого предупреждения.** Достаточно +проверить на своей платформе; смотреть сборки CI не обязательно, хотя и полезно. + +Предупреждения надо чинить по существу — понять, от чего оно, и устранить +причину. Заглушать нельзя: ни понижением уровня говорливости компилятора, ни +`-Wno-…`, ни `#pragma diagnostic ignored`, ни приведением типа ради тишины. + +Предупреждение компилятора — это бесплатный статический анализ, который знает +то, чего не найти ни глазами, ни поиском по тексту. Живой пример: два падения +боевого сервера (issue #3751 и #3752) компилятор назвал заранее строкой +`directive writing 170 bytes into a region of size 162` — русский текст в UTF-8 +занимает вдвое больше, чем занимал в KOI8-R, и фраза перестала влезать в буфер. +Предупреждение лежало в логе сборки всё время, пока по нему не начали падать +процессы. С тех пор именно это переполнение — уже ошибка сборки, а не +предупреждение (`-Werror=format-overflow` в meson.build). + +Имейте в виду: **инкрементальная сборка предупреждения прячет** — уже собранные +файлы не перекомпилируются, и лог выглядит чистым. Когда важно увидеть весь лог, +собирайте с нуля в отдельном каталоге. Старайтесь перед коммитом своих изменений запускать unit-тесты. Старайтесь писать свои unit-тесты для написанного вами кода. Часто ошибки можно обнаружить diff --git a/Dockerfile b/Dockerfile index c3b23fb801..3fe98c75c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,12 @@ ARG WITH_OTEL=false ARG WITH_ADMIN_API=false ARG WITH_YAML=true ARG WITH_SQLITE=false +# Ревизия и счётчик коммитов: внутри контейнера git бесполезен (контекст может быть worktree, +# где .git -- файл-ссылка наружу, или распакованный архив), поэтому пробрасываем их снаружи: +# --build-arg GIT_REV=$(git rev-parse --short HEAD) \ +# --build-arg GIT_COUNT=$(git rev-list --count HEAD) +ARG GIT_REV= +ARG GIT_COUNT= RUN apk add --no-cache \ build-base make meson ninja git cmake samurai \ @@ -61,6 +67,9 @@ RUN if [ "$WITH_SQLITE" = "true" ]; then apk add --no-cache sqlite-dev; fi WORKDIR /mud/mud COPY . /mud/mud +ENV BYLINS_GIT_REV=${GIT_REV} \ + BYLINS_COMMIT_COUNT=${GIT_COUNT} + RUN OTEL_OPT=$([ "$WITH_OTEL" = "true" ] && echo system || echo disabled); \ ADMIN_OPT=$([ "$WITH_ADMIN_API" = "true" ] && echo true || echo false); \ YAML_OPT=$([ "$WITH_YAML" = "true" ] && echo builtin || echo disabled); \ diff --git a/meson.build b/meson.build index 6c15aae6af..36dae0a9a8 100644 --- a/meson.build +++ b/meson.build @@ -58,6 +58,18 @@ if cpp.get_argument_syntax() == 'gcc' project_args += ['-Ofast'] endif project_args += ['-Wno-format-truncation'] + + # Переполнение буфера строкой формата -- ошибка, а не предупреждение. Компилятор считает, + # сколько байт запишет sprintf, и сравнивает с размером буфера; предупреждение об этом + # входит в -Wall и лежало в логе сборки, пока по нему не начали падать боевые процессы + # (#3751, #3752): русский текст в UTF-8 занимает вдвое больше, чем занимал в KOI8-R, + # и фразы, укладывавшиеся впритык, перестали влезать. Числа в коде при этом нет -- растёт + # сам литерал, поэтому глазами и грепом такое не ищется, а компилятором ищется даром. + # + # Уровень по умолчанию, не =2: тот цепляется к теоретической оценке ширины %f. + if cpp.has_argument('-Werror=format-overflow') + project_args += ['-Werror=format-overflow'] + endif if fortify_source # -U на случай, если тулчейн уже определил _FORTIFY_SOURCE (иначе redefine-варнинг) project_args += ['-U_FORTIFY_SOURCE', '-D_FORTIFY_SOURCE=3'] @@ -88,9 +100,6 @@ if host_system == 'windows' endif endif -if cpp.get_id() == 'clang' - project_args += '-Wno-invalid-source-encoding' -endif cpu = host_machine.cpu_family() # GNU-driver compilers only. clang-cl (get_id()=='clang-cl') takes its arch from the MSVC @@ -108,12 +117,7 @@ if host_system == 'windows' if cpp.get_argument_syntax() == 'msvc' # MSVC and clang-cl project_args += ['/MP', '/bigobj', '-DFMT_UNICODE=0', '/external:anglebrackets', '/external:W0'] if cpp.get_id() == 'msvc' - # MSVC accepts the KOI8-R codepage (20866) for source/exec charset. - project_args += ['/source-charset:koi8-r', '/execution-charset:koi8-r'] - else - # clang-cl rejects non-UTF-8 /source-charset ('invalid value koi8-r'); it keeps narrow-literal - # bytes as-is, so just silence the KOI8-R source-encoding warnings (as for the GNU clang driver). - project_args += ['-Wno-invalid-source-encoding'] + project_args += ['/source-charset:utf-8', '/execution-charset:utf-8'] endif elif cpp.get_id() == 'gcc' or cpp.get_id() == 'clang' project_args += ['-Wa,-mbig-obj', '-D__USE_MINGW_ANSI_STDIO=1'] @@ -357,6 +361,15 @@ endif subdir('src/third_party_libs/libfort') dependencies += [fmt_dep, fort_dep] +# Исходники обязаны быть в UTF-8 (issue #3681). Дерево, выкаченное до флипа, при +# переключении ветки остаётся в KOI8-R -- git молчит, дерево числится чистым, а +# собранный отсюда бинарь получает кои-восьмые строковые литералы, и движок расходится +# сам с собой. Ловим это здесь, а не по кракозябрам в логе боевого сервера. +encoding_check = run_command(py3, files('tools/meson/check_sources_utf8.py'), meson.project_source_root(), check: false) +if encoding_check.returncode() != 0 + error(encoding_check.stdout().strip()) +endif + git = find_program('git', required: false) git_rev = 'unknown' if git.found() @@ -645,7 +658,10 @@ main_sources = files( 'src/gameplay/mechanics/title.cpp', 'src/gameplay/statistics/top.cpp', 'src/utils/utils.cpp', + 'src/utils/utf8.cpp', + 'src/utils/native_text.cpp', 'src/utils/utils_encoding.cpp', + 'src/utils/translit_koi8.cpp', 'src/gameplay/mechanics/weather.cpp', 'src/engine/olc/zedit.cpp', 'src/utils/utils_string.cpp', diff --git a/meson_options.txt b/meson_options.txt index bf8598bb5d..91a4d29cc0 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -10,6 +10,9 @@ option('lua_formatter', type: 'boolean', value: true, description: 'Build the em option('with_asan', type: 'boolean', value: false, description: 'Build with Address Sanitizer') option('use_pch', type: 'boolean', value: true, description: 'Use precompiled headers for circle.library') +# KOI8-R -> UTF-8 migration (issue #3681). Selects the engine's native runtime string encoding. +# koi8r = current byte semantics (default); utf8 = character semantics via the utf8:: helpers. + # Options for Admin API and Web features option('admin_api', type: 'boolean', value: false, description: 'Enable Admin API and JSON-related features') diff --git a/src/administration/accounts.cpp b/src/administration/accounts.cpp index 5fcf86a8e7..43691268af 100644 --- a/src/administration/accounts.cpp +++ b/src/administration/accounts.cpp @@ -3,6 +3,7 @@ * 2018 (c) bodrich */ #include "accounts.h" +#include "utils/native_text.h" #include "password.h" #include "engine/entities/zone.h" #include @@ -85,7 +86,7 @@ void Account::show_players(CharData *ch) { ss << "Данные аккаунта: " << this->email << "\r\n"; for (auto &x : this->players_list) { std::string name = GetNameByUnique(x); - name[0] = UPPER(name[0]); + native_text::capitalize_first(name); ss << count << ") " << name << "\r\n"; count++; } @@ -101,7 +102,7 @@ void Account::list_players(DescriptorData *d) { for (auto &x : this->players_list) { std::string name = GetNameByUnique(x); iosystem::write_to_output((std::to_string(count) + ") ").c_str(), d); - name[0] = UPPER(name[0]); + native_text::capitalize_first(name); iosystem::write_to_output(name.c_str(), d); iosystem::write_to_output("\r\n", d); count++; @@ -244,7 +245,10 @@ void Account::set_password(const std::string &password) { } bool Account::compare_password(const std::string &password) { - return CompareParam(this->hash_password, CRYPT(password.c_str(), this->hash_password.c_str()), true); + // Тот же приём, что в Password::compare_password: хэш посчитан по дисковым байтам, + // поэтому пароль приводим к дисковой кодировке перед crypt (issue #3681). + return CompareParam(this->hash_password, + CRYPT(native_text::to_disk(password).c_str(), this->hash_password.c_str()), true); } bool Account::quest_is_available(int id) { diff --git a/src/administration/name_adviser.cpp b/src/administration/name_adviser.cpp index e8a3f49757..755bd4eb3c 100644 --- a/src/administration/name_adviser.cpp +++ b/src/administration/name_adviser.cpp @@ -4,6 +4,7 @@ #include "engine/entities/char_player.h" #include "engine/db/global_objects.h" +#include "utils/native_text.h" NameAdviser::NameAdviser() { std::srand(static_cast((std::time(nullptr)))); @@ -106,6 +107,8 @@ void NameAdviser::init() { std::string line; while (std::getline(approved_names_file, line)) { + // Граница чтения: файл лежит в кодировке мира (issue #3681). + line = native_text::from_disk_line(line.c_str()); std::istringstream iss(line); std::string char_name; @@ -127,21 +130,16 @@ void NameAdviser::init() { } bool NameAdviser::is_names_similar(const std::string &left, const std::string &right) { - if ((left.length() < kMinNameLength) || (right.length() < kMinNameLength)) { + // Сравниваются первые kMinNameLength символов без учёта регистра. По байтам это была бы + // половина русского имени, да ещё и с побайтовым UPPER, который кириллицу не берёт (#3681). + if ((native_text::char_count(left) < kMinNameLength) || (native_text::char_count(right) < kMinNameLength)) { return false; } - std::string short_left = left.substr(0, kMinNameLength); - for (auto &ch: short_left) { - ch = UPPER(ch); - } - - std::string short_rigth = right.substr(0, kMinNameLength); - for (auto &ch: short_rigth) { - ch = UPPER(ch); - } + const std::string short_left = left.substr(0, native_text::char_offset(left, kMinNameLength)); + const std::string short_right = right.substr(0, native_text::char_offset(right, kMinNameLength)); - return short_left == short_rigth; + return native_text::compare_ci(short_left, short_right) == 0; } void NameAdviser::remove_duplicates() { diff --git a/src/administration/names.cpp b/src/administration/names.cpp index 442d7a7bb0..451ddfc31d 100644 --- a/src/administration/names.cpp +++ b/src/administration/names.cpp @@ -9,6 +9,7 @@ * $Revision$ * ************************************************************************ */ +#include "utils/native_text.h" #include "names.h" #include "utils/grammar/gender.h" @@ -128,6 +129,8 @@ static void NewNames::save() { for (const auto &it : NewNameList) { lines.push_back(it.first); } + // Кодировку диска держит сам StateManager -- здесь конвертировать уже не надо, + // иначе получилась бы двойная конверсия (issue #3681). MUD::StateManager().SaveLines(state::EStateFile::kPendingNames, lines); } @@ -397,9 +400,7 @@ int IsValidName(char *newname) { // change to lowercase char tempname[kMaxInputLength]; strcpy(tempname, newname); - for (std::size_t i = 0; tempname[i]; i++) { - tempname[i] = LOWER(tempname[i]); - } + native_text::to_lower(tempname); // Does the desired name contain a string in the invalid list? for (std::size_t i = 0; i < num_invalid; i++) { diff --git a/src/administration/password.cpp b/src/administration/password.cpp index d3df2b664b..cf6191ee10 100644 --- a/src/administration/password.cpp +++ b/src/administration/password.cpp @@ -9,6 +9,7 @@ #include "engine/ui/interpreter.h" #include "engine/entities/char_data.h" #include "engine/entities/char_player.h" +#include "utils/native_text.h" // для ручного отключения крипования (на локалке лучше собирайте через make test и не парьтесь) //#define NOCRYPT // в случае сборки без криптования просто пишем пароль в открытом виде @@ -28,6 +29,14 @@ namespace Password { +// Хэш пароля -- сохранённые данные, и посчитан он когда-то по дисковым байтам (KOI8-R). +// Движок теперь держит текст нативным, поэтому кириллический пароль дал бы другие байты +// и не сошёлся бы с сохранённым хэшем. Приводим к дисковой форме перед crypt: старые хэши +// продолжают сходиться, а новые остаются пригодными для отката (issue #3681). +static std::string password_bytes(const std::string &pwd) { + return native_text::to_disk(pwd); +} + const char *BAD_PASSWORD = "Пароль должен быть от 8 до 50 символов и не должен быть именем персонажа."; const unsigned int MIN_PWD_LENGTH = 8; const unsigned int MAX_PWD_LENGTH = 50; @@ -35,7 +44,10 @@ const unsigned int MAX_PWD_LENGTH = 50; // * Генерация хэша с более-менее рандомным сальтом std::string generate_md5_hash(const std::string &pwd) { #ifdef NOCRYPT - return pwd; + // И здесь дисковая форма: сравнение всё равно приводит пароль к ней, а хранить + // нативную значило бы, что кириллический пароль не сойдётся сам с собой. Сборки + // без crypt() -- это Windows, macOS и -Dnocrypt=true (issue #3681). + return password_bytes(pwd); #else char key[14]; key[0] = '$'; @@ -54,7 +66,7 @@ std::string generate_md5_hash(const std::string &pwd) { } key[12] = '$'; key[13] = '\0'; - return CRYPT(pwd.c_str(), key); + return CRYPT(password_bytes(pwd).c_str(), key); #endif } @@ -108,10 +120,10 @@ bool get_password_type(const CharData *ch) { bool compare_password(CharData *ch, const std::string &pwd) { bool result = 0; if (get_password_type(ch)) - result = CompareParam(ch->get_passwd(), CRYPT(pwd.c_str(), ch->get_passwd().c_str()), 1); + result = CompareParam(ch->get_passwd(), CRYPT(password_bytes(pwd).c_str(), ch->get_passwd().c_str()), 1); else { // если пароль des сошелся - конвертим сразу в md5 (10 - бывший MAX_PWD_LENGTH) - char *s = (char *) CRYPT(pwd.c_str(), ch->get_passwd().c_str()); + char *s = (char *) CRYPT(password_bytes(pwd).c_str(), ch->get_passwd().c_str()); if (s && !strncmp(s, ch->get_passwd().c_str(), 10)) { set_password(ch, pwd); result = 1; @@ -131,7 +143,13 @@ bool compare_password(CharData *ch, const std::string &pwd) { bool check_password(const CharData *ch, const char *pwd) { // при вырубленном криптовании на локалке пароль можно ставить любой #ifndef NOCRYPT - if (!pwd || !str_cmp(pwd, GET_PC_NAME(ch)) || strlen(pwd) > MAX_PWD_LENGTH || strlen(pwd) < MIN_PWD_LENGTH) + if (!pwd) { + return 0; + } + // Длина считается в символах: с UTF-8 кириллица занимает по два байта, и по strlen + // восьмибуквенный русский пароль выглядел бы шестнадцатисимвольным (issue #3681). + const std::size_t length = native_text::char_count(pwd); + if (!str_cmp(pwd, GET_PC_NAME(ch)) || length > MAX_PWD_LENGTH || length < MIN_PWD_LENGTH) return 0; #else UNUSED_ARG(ch); @@ -145,7 +163,7 @@ bool check_password(const CharData *ch, const char *pwd) { * \return 0 - не сошлось, 1 - сошлось */ bool compare_password(std::string const &hash, std::string const &pass) { - return CompareParam(hash.c_str(), CRYPT(pass.c_str(), hash.c_str()), 1); + return CompareParam(hash.c_str(), CRYPT(password_bytes(pass).c_str(), hash.c_str()), 1); } } // namespace Password diff --git a/src/engine/boot/boot_data_files.cpp b/src/engine/boot/boot_data_files.cpp index 5613d7b39a..6b2df297b7 100644 --- a/src/engine/boot/boot_data_files.cpp +++ b/src/engine/boot/boot_data_files.cpp @@ -1,4 +1,5 @@ #include "boot_data_files.h" +#include "utils/native_text.h" #include "engine/db/obj_prototypes.h" #include "engine/scripting/dg_olc.h" @@ -24,7 +25,7 @@ class DataFile : public BaseDataFile { void close() override; [[nodiscard]] const std::string &file_name() const { return m_file_name; } [[nodiscard]] const auto &file() const { return m_file; } - void get_one_line(char *buf); + void get_one_line(char *buf, std::size_t buf_size); private: FILE *m_file; @@ -44,14 +45,26 @@ void DataFile::close() { fclose(m_file); } -void DataFile::get_one_line(char *buf) { - if (fgets(buf, READ_SIZE, file()) == nullptr) { +void DataFile::get_one_line(char *buf, std::size_t buf_size) { + char raw[READ_SIZE]; + if (fgets(raw, sizeof(raw), file()) == nullptr) { mudlog("SYSERR: error reading help file: not terminated with $?", DEF, kLvlImmortal, SYSLOG, true); buf[0] = '$'; buf[1] = 0; return; } - buf[strlen(buf) - 1] = '\0'; // take off the trailing \n + size_t len = strlen(raw); + while (len > 0 && (raw[len - 1] == '\n' || raw[len - 1] == '\r')) { + raw[--len] = '\0'; // take off the trailing \r\n + } + + // Граница кодировки (issue #3681): файлы справки лежат на диске в KOI8-R, а движок под + // UTF-8 ждёт нативный текст -- без этого ключи разделов не совпадали бы с тем, что ввёл + // игрок, а тело раздела уезжало бы клиенту байтами чужой кодировки. Строка может при этом + // вырасти (KOI8-R байт -> до трёх байт UTF-8), поэтому размер буфера передаётся явно. + const std::string native = native_text::from_disk_line(raw); + strncpy(buf, native.c_str(), buf_size - 1); + buf[buf_size - 1] = '\0'; } class DiscreteFile : public DataFile { @@ -330,7 +343,9 @@ void TriggersFile::LoadDgTriggerScript(Trigger *trig, const std::string &cmds, i // lowercase the command (first word) for faster comparison at runtime auto it = (*ptr)->cmd.begin(); while (it != (*ptr)->cmd.end() && (*it == ' ' || *it == '\t')) ++it; - while (it != (*ptr)->cmd.end() && *it != ' ') { *it = LOWER(*it); ++it; } + for (; it != (*ptr)->cmd.end() && *it != ' '; it += native_text::char_bytes(&*it)) { + native_text::copy_lower_char(&*it, &*it); + } ptr = &(*ptr)->next; } if (pos_end != std::string::npos) @@ -397,7 +412,7 @@ void WorldFile::parse_room(int virtual_nr) { world[room_realnum]->zone_rn = zone; world[room_realnum]->vnum = virtual_nr; std::string tmpstr = fread_string(); - tmpstr[0] = UPPER(tmpstr[0]); + native_text::capitalize_first(tmpstr); world[room_realnum]->set_name(tmpstr); // if (zone_table[zone].RnumRoomsLocation.first == -1) { // zone_table[zone].RnumRoomsLocation.first = room_realnum; @@ -1729,20 +1744,23 @@ class HelpFile : public DataFile { }; bool HelpFile::load_help() { + // В нативной кодировке строка может быть втрое длиннее прочитанной с диска (KOI8-R байт + // разворачивается в UTF-8 максимум в три), поэтому буферы с запасом (issue #3681). + constexpr size_t kLineBufSize = 3 * READ_SIZE + 1; #if defined(CIRCLE_MACINTOSH) - static char key[READ_SIZE + 1], next_key[READ_SIZE + 1], entry[32384]; // ? + static char key[kLineBufSize], next_key[kLineBufSize], entry[32384]; // ? #else - char key[READ_SIZE + 1], next_key[READ_SIZE + 1], entry[32384]; + char key[kLineBufSize], next_key[kLineBufSize], entry[32384]; #endif - char line[READ_SIZE + 1]; + char line[kLineBufSize]; const char *scan; // get the first keyword line - get_one_line(key); + get_one_line(key, sizeof(key)); while (*key != '$') // read in the corresponding help entry { snprintf(entry, sizeof(entry), "%s\r\n", key); - get_one_line(line); + get_one_line(line, sizeof(line)); while (*line != '#') { // если вдруг файл внезапно закончился и '#' так и не встретился // логаем ошибку и заканчиваем парсинг во избежание зацикливания @@ -1754,7 +1772,7 @@ bool HelpFile::load_help() { } size_t entry_len = strlen(entry); snprintf(entry + entry_len, sizeof(entry) - entry_len, "%s\r\n", line); - get_one_line(line); + get_one_line(line, sizeof(line)); } // Assign read level int min_level = 0; @@ -1772,7 +1790,7 @@ bool HelpFile::load_help() { } // get next keyword line (or $) - get_one_line(key); + get_one_line(key, sizeof(key)); } return true; diff --git a/src/engine/boot/state_manager.cpp b/src/engine/boot/state_manager.cpp index 92cc55279a..2845d5a578 100644 --- a/src/engine/boot/state_manager.cpp +++ b/src/engine/boot/state_manager.cpp @@ -11,6 +11,7 @@ #include #include "utils/logger.h" +#include "utils/native_text.h" namespace state { @@ -61,7 +62,10 @@ std::vector StateManager::LoadLines(EStateFile file) const { if (!line.empty() && line.back() == '\r') { line.pop_back(); // tolerate CRLF files } - lines.push_back(std::move(line)); + // Граница чтения: списки лежат на диске в кодировке мира (сейчас KOI8-R), в память + // идут нативными. Здесь же имена персонажей, титулы и баны, и без пары к to_disk + // на записи они уезжали в транслит при первой же перезаписи файла (issue #3681). + lines.push_back(native_text::from_disk_line(line.c_str())); } return lines; } @@ -76,7 +80,7 @@ bool StateManager::SaveLines(EStateFile file, const std::vector &li return false; } for (const auto &l : lines) { - out << l << '\n'; + out << native_text::to_disk(l) << '\n'; // граница записи, зеркало к LoadLines } out.flush(); if (!out.good()) { @@ -105,7 +109,7 @@ bool StateManager::AppendLine(EStateFile file, const std::string &line) const { log("SYSERR: StateManager: cannot open '%s' for append", path.c_str()); return false; } - out << line << '\n'; + out << native_text::to_disk(line) << '\n'; // граница записи, зеркало к LoadLines return out.good(); } diff --git a/src/engine/core/comm.cpp b/src/engine/core/comm.cpp index 377ffb71fa..0567a84ed2 100644 --- a/src/engine/core/comm.cpp +++ b/src/engine/core/comm.cpp @@ -923,9 +923,6 @@ void stop_game(ush_int port) { } #endif - // Shutdown OTEL providers to flush remaining telemetry - observability::OtelProvider::Instance().Shutdown(); - FlushPlayerIndex(); // храны надо сейвить до Crash_save_all_rent(), иначе будем брать бабло у чара при записи @@ -1020,9 +1017,15 @@ void stop_game(ush_int port) { } if (shutdown_parameters.reboot_after_shutdown()) { log("Rebooting."); + // Гасим телеметрию последней: Shutdown() дожимает буфер в коллектор и переводит + // вывод на файл. Стояла она раньше в начале stop_game -- и весь хвост выключения + // (сохранение ренты, сброс очереди зон, "Closing all sockets", "Rebooting") уходил + // в никуда: в файловом сислоге он есть, а в Loki этих строк не было вовсе. + observability::OtelProvider::Instance().Shutdown(); exit(52); // what's so great about HHGTTG, anyhow? } log("Normal termination of game."); + observability::OtelProvider::Instance().Shutdown(); // см. комментарий выше } /* diff --git a/src/engine/core/iosystem.cpp b/src/engine/core/iosystem.cpp index a1c3c80ac2..b94abf23fd 100644 --- a/src/engine/core/iosystem.cpp +++ b/src/engine/core/iosystem.cpp @@ -7,6 +7,9 @@ */ #include "engine/core/iosystem.h" +#include "utils/native_text.h" +#include +#include #include "gameplay/core/experience.h" #include "administration/privilege.h" #include "utils/utils_encoding.h" @@ -150,10 +153,20 @@ void write_to_output(const char *txt, DescriptorData *t) { // bufptr == ~0ull в начале функции), а игрок об этом не узнает. Пишем в сислог, кому и // на какой команде не хватило буфера -- иначе такие случаи видны только счётчиком в // "показать статистика", без единой подробности. + // last_input заполняется на ЛЮБОЙ строке от клиента -- проверки состояния над той + // записью нет, -- так что в парольных состояниях там лежит сам пароль. В лог его + // отдавать нельзя: сислог читают и хранят. + const bool secret_input = t->state == EConState::kPassword + || t->state == EConState::kNewpasswd + || t->state == EConState::kCnfpasswd + || t->state == EConState::kChpwdGetOld + || t->state == EConState::kChpwdGetNew + || t->state == EConState::kChpwdVrfy + || t->state == EConState::kDelcnf1; log("SYSERR: output overflow: %s [%s], команда '%s', в буфере %zu б, отброшено %zu б", t->character ? GET_NAME(t->character) : "<без персонажа>", *t->host ? t->host : "?", - t->last_input, + secret_input ? "<ввод скрыт>" : t->last_input, t->bufspace, size); return; } @@ -435,8 +448,20 @@ int process_input(DescriptorData *t) { // Увы, это кое-что ломает, напр. wizhelp, или "г я использую zMUD" if (t->state == EConState::kPlaying || (t->state == EConState::kExdesc)) { if (t->keytable == kCodePageWinzZ || t->keytable == kCodePageWinzOld) { - if (*(write_point - 1) == 'z') { - *(write_point - 1) = 'я'; + // Буква задана СТРОКОВЫМ литералом: он байт-прозрачен, поэтому один и тот + // же код верен и для KOI8-R (1 байт), и для UTF-8 (2 байта) -- в отличие от + // символьного литерала, который под UTF-8 не помещается в char (issue #3681). + // В этой точке буфер содержит KOI8-R (таблицы легаси-кодировок выше отдают + // именно его), поэтому букву тоже берём в KOI8-R. Под KOI8-R-рантаймом + // to_koi8 - тождество, под UTF-8 - перекодировка литерала. + static const std::string kYaStorage = native_text::to_koi8("я"); + const std::string_view kYaLetter = kYaStorage; + if (*(write_point - 1) == 'z' && space_left + 1 >= kYaLetter.size()) { + --write_point; + ++space_left; + std::memcpy(write_point, kYaLetter.data(), kYaLetter.size()); + write_point += kYaLetter.size(); + space_left -= kYaLetter.size(); } } } @@ -445,20 +470,19 @@ int process_input(DescriptorData *t) { *write_point = '\0'; - if (t->keytable == kCodePageUTF8) { - int i; - char utf8_tmp[kMaxSockBuf * 2 * 3]; - size_t len_i, len_o; - - len_i = strlen(tmp); - - for (i = 0; i < kMaxSockBuf * 2 * 3; i++) { - utf8_tmp[i] = 0; - } - codepages::utf8_to_koi(tmp, utf8_tmp); - len_o = strlen(utf8_tmp); - strncpy(tmp, utf8_tmp, kMaxInputLength - 1); - space_left = space_left + len_i - len_o; + // Приводим собранную строку к нативной кодировке движка (issue #3681). После разбора + // выше в tmp лежит либо UTF-8 (клиент UTF-8), либо KOI8-R (все остальные кодировки + // клиентов - их таблицы отдают именно KOI8-R). + if (t->keytable != kCodePageUTF8) { + const size_t len_i = strlen(tmp); + const std::string native = native_text::from_koi8(tmp); + // После конверсии строка длиннее (кириллица идёт по два байта), так что в буфер + // она может не влезть. Отступаем до границы символа: обрезать посреди символа -- + // значит отдать движку битый UTF-8 (issue #3681). + const std::size_t fits = native_text::truncate_offset(native, kMaxInputLength - 1); + std::memcpy(tmp, native.data(), fits); + tmp[fits] = '\0'; + space_left = space_left + len_i - strlen(tmp); } if ((space_left <= 0) && (ptr < nl_pos)) { @@ -582,6 +606,8 @@ int perform_subst(DescriptorData *t, char *orig, char *subst) { // terminate the string in case of an overflow from strncat newsub[kMaxInputLength - 1] = '\0'; + // ...и, если strncat оборвал строку посреди символа, отступаем до его границы (issue #3681). + newsub[native_text::truncate_offset(newsub, strlen(newsub))] = '\0'; strcpy(subst, newsub); return (0); diff --git a/src/engine/core/target_resolver.cpp b/src/engine/core/target_resolver.cpp index 03b0fd8535..ed57c16d15 100644 --- a/src/engine/core/target_resolver.cpp +++ b/src/engine/core/target_resolver.cpp @@ -872,13 +872,18 @@ int find_all_dots(char *arg) { if (!str_cmp(arg, "all") || !str_cmp(arg, "все")) { return (kFindAll); - } else if (!strn_cmp(arg, "all.", 4) || !strn_cmp(arg, "все.", 4)) { - strl_cpy(tmpname, arg + 4, kMaxInputLength); - strl_cpy(arg, tmpname, kMaxInputLength); - return (kFindAlldot); - } else { - return (kFindIndiv); } + // Длину префикса берём из самого литерала: в UTF-8 "все." -- семь байт, а не четыре, и + // жёсткая четвёрка отрезала полторы буквы, оставляя "е.<имя>" (issue #3681). + for (const char *prefix : {"all.", "все."}) { + const size_t prefix_len = strlen(prefix); + if (utils::IsAbbr(prefix, arg)) { + strl_cpy(tmpname, arg + prefix_len, kMaxInputLength); + strl_cpy(arg, tmpname, kMaxInputLength); + return (kFindAlldot); + } + } + return (kFindIndiv); } RoomRnum FindRoomRnum(CharData *ch, char *rawroomstr, int trig) { diff --git a/src/engine/db/db.cpp b/src/engine/db/db.cpp index 9f51e53f85..886b5ee0c0 100644 --- a/src/engine/db/db.cpp +++ b/src/engine/db/db.cpp @@ -1,9 +1,11 @@ +#include #include // malloc.h и malloc_trim есть только у glibc: на macOS заголовок называется иначе #ifdef __GLIBC__ #include #endif #include +#include "utils/native_text.h" #include "gameplay/affects/affect_messages.h" #include "gameplay/abilities/feats.h" // issue.perk-action-patching: BuildTalentPatchIndex #include "utils/utils_encoding.h" @@ -339,9 +341,14 @@ int ConvertPotionToEValueKey(CObjectPrototype *obj, bool proto) { // MUD::Spell().IsValid() here -- ConvertObjValues runs before the spell registry is populated, so // it would reject every spell. It isn't needed either: an out-of-range/undefined spell number is // silently ignored at cast, exactly as the m_vals path already does. - const auto set_spell = [obj](ObjVal::EValueKey key, int num) { + // Помечать зону на сохранение имеет смысл, только если ключи действительно появились: + // у предмета с нулевыми val[] писать нечего, guard выше на следующем буте снова окажется + // ложным, и зона переписывалась бы вхолостую каждый старт (issue #3749). + bool migrated = false; + const auto set_spell = [obj, &migrated](ObjVal::EValueKey key, int num) { if (num > 0) { obj->SetPotionValueKey(key, num); + migrated = true; } }; set_spell(ObjVal::EValueKey::kSpell1Num, v1); @@ -356,10 +363,11 @@ int ConvertPotionToEValueKey(CObjectPrototype *obj, bool proto) { } if (brewed) { obj->SetPotionValueKey(ObjVal::EValueKey::kPotionPotency, v3); + migrated = true; } else { set_spell(ObjVal::EValueKey::kSpell3Num, v3); } - return 1; + return migrated ? 1 : 0; } /// конверт параметров прототипов ПОСЛЕ лоада всех файлов с прототипами @@ -405,8 +413,10 @@ int ConvertSpellItemToEValueKey(CObjectPrototype *obj, bool /*proto*/) { || obj->GetPotionValueKey(ObjVal::EValueKey::kMakerSkill) >= 0) { return 0; // already migrated } - const auto set_pos = [obj](ObjVal::EValueKey key, int num) { - if (num > 0) { obj->SetPotionValueKey(key, num); } + // Как и у зелий: без единого выставленного ключа сохранять нечего (issue #3749). + bool migrated = false; + const auto set_pos = [obj, &migrated](ObjVal::EValueKey key, int num) { + if (num > 0) { obj->SetPotionValueKey(key, num); migrated = true; } }; if (type == EObjType::kScroll) { set_pos(ObjVal::EValueKey::kSpell1Num, obj->get_val(1)); @@ -417,7 +427,7 @@ int ConvertSpellItemToEValueKey(CObjectPrototype *obj, bool /*proto*/) { set_pos(ObjVal::EValueKey::kMaxCharges, obj->get_val(1)); set_pos(ObjVal::EValueKey::kCurCharges, obj->get_val(2)); } - return 1; + return migrated ? 1 : 0; } // issue.magic-items-hotfix: the liquid core is stored in the kLiquid* keys (get_val/set_val redirect @@ -450,11 +460,15 @@ int ConvertDrinkconLiquidCore(CObjectPrototype *obj, bool proto) { obj->SetPotionValueKey(ObjVal::EValueKey::kLiquidCapacity, capacity); obj->SetPotionValueKey(ObjVal::EValueKey::kLiquidCurrent, obj->get_val(1)); obj->SetPotionValueKey(ObjVal::EValueKey::kLiquidType, obj->get_val(2)); - return 1; + // Зону не помечаем. Жидкостное ядро уходит на диск как обычные values (get_val/set_val + // перенаправлены в эти же ключи), нового на диске не появляется, а сам засев по замыслу + // повторяется на каждой загрузке -- иначе зона переписывалась бы вечно (issue #3749). + return 0; } void ConvertObjValues() { int save = 0; + std::set marked; for (const auto &i : obj_proto) { save = std::max(save, ConvertDrinkconSkillField(i.get(), true)); save = std::max(save, ConvertDrinkPoisonField(i.get(), true)); @@ -471,10 +485,21 @@ void ConvertObjValues() { } // ... if (save) { - olc_add_to_save_list(i->get_vnum() / 100, OLC_SAVE_OBJ); + const int zone = i->get_vnum() / 100; + olc_add_to_save_list(zone, OLC_SAVE_OBJ); + marked.insert(zone); save = 0; } } + // Молчит, когда помечать нечего. Если зоны в этом списке повторяются от бута к буту -- + // значит миграция снова не персистится, как было в issue #3749. + if (!marked.empty()) { + std::string zones; + for (const int zone : marked) { + zones += std::to_string(zone) + " "; + } + log("Converted obj values, zones queued for save: %s", zones.c_str()); + } } namespace { @@ -1034,11 +1059,18 @@ void ZoneTrafficSave() { zone_node.append_attribute("traffic") = i.traffic; } - doc.save_file(MUD::StateManager().Path(state::EStateFile::kZoneTraffic).c_str()); + // Граница записи: XML уходит на диск в кодировке мира, а не в нативной + // (issue #3681). + std::ostringstream xml; + doc.save(xml, "\t", pugi::format_default, pugi::encoding_utf8); + native_text::write_file(MUD::StateManager().Path(state::EStateFile::kZoneTraffic), xml.str()); } void zone_traffic_load() { pugi::xml_document doc; - pugi::xml_parse_result result = doc.load_file(MUD::StateManager().Path(state::EStateFile::kZoneTraffic).c_str()); + // Файл лежит на диске в KOI8-R; читаем через границу кодировки, а разбираем уже + // буфер в нативной кодировке движка (issue #3681). Под KOI8-R это тождество. + const std::string xml_db = native_text::read_data_file(MUD::StateManager().Path(state::EStateFile::kZoneTraffic)); + pugi::xml_parse_result result = doc.load_buffer(xml_db.data(), xml_db.size()); if (!result) { snprintf(buf, kMaxStringLength, "...%s", result.description()); mudlog(buf, CMP, kLvlImmortal, SYSLOG, true); @@ -3503,6 +3535,7 @@ int ReadFileToBuffer(const char *name, char *destination_buf) { // Цикл идёт по результату fgets. Прежний do/while обрабатывал tmp и после неудачного чтения: // на пустом файле strlen() шёл по неинициализированной памяти, а при нулевой длине // tmp[strlen(tmp) - 1] писал байт ПЕРЕД началом буфера. + std::string text; while (fgets(tmp, READ_SIZE, fl)) { const size_t len = strlen(tmp); // перевод строки снимаем, только если он есть: у строки длиннее READ_SIZE и у последней @@ -3510,19 +3543,22 @@ int ReadFileToBuffer(const char *name, char *destination_buf) { if (len > 0 && tmp[len - 1] == '\n') { tmp[len - 1] = '\0'; } - strcat(tmp, "\r\n"); - - if (strlen(destination_buf) + strlen(tmp) + 1 > kMaxExtendLength) { - log("SYSERR: %s: string too big (%d max)", name, kMaxStringLength); - *destination_buf = '\0'; - fclose(fl); - return (-1); - } - strcat(destination_buf, tmp); + text += tmp; + text += "\r\n"; } fclose(fl); + // Тексты лежат на диске в KOI8-R, движок держит их в нативной кодировке. Без перевода + // экран справки уезжал игроку сырыми байтами и выглядел кашей (issue #3681). + const std::string native = native_text::from_disk_text(text); + if (native.size() + 1 > kMaxExtendLength) { + log("SYSERR: %s: string too big (%d max)", name, kMaxStringLength); + *destination_buf = '\0'; + return (-1); + } + memcpy(destination_buf, native.c_str(), native.size() + 1); + return (0); } @@ -3661,7 +3697,7 @@ Rooms::~Rooms() { int get_filename(const char *orig_name, char *filename, int mode) { const char *prefix, *middle, *suffix; - char name[64], *ptr; + char name[64]; if (orig_name == nullptr || *orig_name == '\0' || filename == nullptr) { log("SYSERR: NULL pointer or empty string passed to get_filename(), %p or %p.", orig_name, filename); @@ -3696,13 +3732,9 @@ int get_filename(const char *orig_name, char *filename, int mode) { default: return (0); } - strcpy(name, orig_name); - for (ptr = name; *ptr; ptr++) { - if (*ptr == 'Ё' || *ptr == 'ё') - *ptr = '9'; - else - *ptr = LOWER(codepages::AtoL(*ptr)); - } + // Транслитерация вынесена в native_text: имя файла игрока обязано совпадать до и после + // смены кодировки, иначе сохранёнки перестанут находиться (issue #3681). + strcpy(name, native_text::translit_to_filename(orig_name).c_str()); switch (LOWER(*name)) { case 'a': diff --git a/src/engine/db/help.cpp b/src/engine/db/help.cpp index d19348f6b3..cc166766ce 100644 --- a/src/engine/db/help.cpp +++ b/src/engine/db/help.cpp @@ -586,12 +586,12 @@ std::string OutRecipiesHelp(ECharClass ch_class) { utils::SortKoiString(skills_list); for (auto it : skills_list) { tmpstr = !(++columns % 2) ? "\r\n" : "\t"; - out << "\t" << std::left << std::setw(30) << it << tmpstr; + out << "\t" << fmt::format("{:<30}", it) << tmpstr; } utils::SortKoiString(skills_list2); for (auto it : skills_list2) { tmpstr = !(++columns2 % 2) ? "\r\n" : "\t"; - out2 << "\t" << "&C" << std::left << std::setw(30) << it << "&n" << tmpstr; + out2 << "\t" << "&C" << fmt::format("{:<30}", it) << "&n" << tmpstr; } if (out.str().back() == '\t') out << "\r\n"; @@ -711,20 +711,20 @@ std::string OutSkillsHelp(ECharClass ch_class) { } } if (num == 1) { - skills_list2.push_back(utils::SubstKtoW(skill.GetName())); + skills_list2.push_back(skill.GetName()); continue; } - skills_list.push_back(utils::SubstKtoW(skill.GetName())); + skills_list.push_back(skill.GetName()); } std::sort(skills_list.begin(), skills_list.end()); for (auto it : skills_list) { tmpstr = !(++columns % 2) ? "\r\n" : "\t"; - out << "\t" << std::left << std::setw(30) << utils::SubstWtoK(it) << tmpstr; + out << "\t" << fmt::format("{:<30}", it) << tmpstr; } std::sort(skills_list2.begin(), skills_list2.end()); for (auto it : skills_list2) { tmpstr = !(++columns2 % 2) ? "\r\n" : "\t"; - out2 << "\t" << "&C" << std::left << std::setw(30) << utils::SubstWtoK(it) << "&n" << tmpstr; + out2 << "\t" << "&C" << fmt::format("{:<30}", it) << "&n" << tmpstr; } if (out.str().back() == '\t') out << "\r\n"; @@ -970,17 +970,17 @@ std::string OutFeatureHelp(ECharClass ch_class) { utils::SortKoiString(feat_list); for (auto it : feat_list) { tmpstr = !(++columns % 2) ? "\r\n" : "\t"; - out << "\t" << std::left << std::setw(30) << it << tmpstr; + out << "\t" << fmt::format("{:<30}", it) << tmpstr; } utils::SortKoiString(feat_list2); for (auto it : feat_list2) { tmpstr = !(++columns2 % 2) ? "\r\n" : "\t"; - out2 << "\t" << "&C" << std::left << std::setw(30) << it << "&n" << tmpstr; + out2 << "\t" << "&C" << fmt::format("{:<30}", it) << "&n" << tmpstr; } utils::SortKoiString(feat_list3); for (auto it : feat_list3) { tmpstr = !(++columns3 % 2) ? "\r\n" : "\t"; - out3 << "\t" << "&C" << std::left << std::setw(30) << it << "&n" << tmpstr; + out3 << "\t" << "&C" << fmt::format("{:<30}", it) << "&n" << tmpstr; } if (out.str().back() == '\t') out << "\r\n"; @@ -1285,8 +1285,12 @@ void SetsHelp() { str_out = "все"; else if (count_class == 0) str_out = "никто"; - if (str_out.back() == '\n') - str_out.back() = '\0'; + // Именно pop_back(), а не запись '\0': присваивание оставляло NUL ВНУТРИ строки, + // не меняя её длины. Байтовая таблица это терпела, а libfort в режиме utf8_table + // на встроенном нуле уводит расчёт ширины в переполнение и роняет процесс. + if (str_out.back() == '\n') { + str_out.pop_back(); + } table << str_out << table_wrapper::kSeparator << table_wrapper::kEndRow; } table.SetColumnAlign(0, table_wrapper::align::kRight); diff --git a/src/engine/db/obj_save.cpp b/src/engine/db/obj_save.cpp index 26298fca4e..a71db18e06 100644 --- a/src/engine/db/obj_save.cpp +++ b/src/engine/db/obj_save.cpp @@ -11,6 +11,7 @@ #include "engine/core/char_handler.h" #include "gameplay/affects/obj_affects.h" +#include "utils/native_text.h" #include "gameplay/mechanics/equipment.h" #include "obj_save.h" #include "gameplay/mechanics/groups.h" @@ -1593,10 +1594,21 @@ int Crash_load(CharData *ch) { }; fclose(fl); // Сверка CRC из уже прочитанного буфера, без повторного чтения файла. + // Считается по дисковым байтам -- до перевода в нативную кодировку, иначе сумма не сойдётся. FileCRC::verify_from_content(ch->get_uid(), FileCRC::kTextObjs, readdata, fsize); + // Граница чтения: файл лежит в кодировке мира (сейчас KOI8-R), в память вещи идут + // нативными -- зеркало к to_disk на записи. Без этого имена, алиасы и метки вещей + // уезжают в транслит при первом же сохранении (issue #3681). + { + const std::string native = native_text::from_disk_text(std::string(readdata, static_cast(fsize))); + free(readdata); + CREATE(readdata, native.size() + 1); + std::memcpy(readdata, native.data(), native.size()); + readdata[native.size()] = '\0'; + } + data = readdata; - *(data + fsize) = '\0'; //Создание объектов long timer_dec = time(0) - SAVEINFO(index)->rent.time; @@ -1662,7 +1674,7 @@ int Crash_load(CharData *ch) { } std::string cap = obj->get_PName(grammar::ECase::kNom); - cap[0] = UPPER(cap[0]); + native_text::capitalize_first(cap); // Предмет разваливается от старости if (obj->get_timer() <= 0) { @@ -2126,7 +2138,10 @@ int save_char_objects(CharData *ch, int savetype, int rentcost) { Crash_delete_files(iplayer); return false; } - file.write(obj_content.data(), static_cast(obj_content.size())); + // Граница записи: рента уходит на диск в кодировке мира (сейчас KOI8-R), зеркально + // чтению -- иначе первое сохранение переводит файл в UTF-8 (issue #3681). + const std::string on_disk = native_text::to_disk(obj_content); + file.write(on_disk.data(), static_cast(on_disk.size())); file.close(); /* оставил как пример #ifndef _WIN32 @@ -2139,7 +2154,7 @@ int save_char_objects(CharData *ch, int savetype, int rentcost) { */ utils::CExecutionTimer crc_timer; FileCRC::update_from_content(ch->get_uid(), FileCRC::kTextObjs, - obj_content.data(), obj_content.size()); + on_disk.data(), on_disk.size()); crc_sec = crc_timer.delta().count(); g_obj_crash_save_hash[ch->get_uid()] = content_hash; } @@ -2256,32 +2271,27 @@ void Crash_report_rent_item(CharData *ch, int factor, int equip, int recursive) { - static char buf[256]; - char bf[80], bf2[14]; - - if (obj) { - if (CAN_WEAR_ANY(obj)) { - if (equip) { - sprintf(bf, " (%d если снять)", obj->get_rent_off() * factor * count); - } else { - sprintf(bf, " (%d если надеть)", obj->get_rent_on() * factor * count); - } - } else { - *bf = '\0'; - } - - if (count > 1) { - sprintf(bf2, " [%d]", count); - } - - sprintf(buf, "%s - %d %s%s за %s%s %s", - recursive ? "" : kColorWht, - (equip ? obj->get_rent_on() * count : obj->get_rent_off()) * - factor * count, - MUD::Currency(currencies::kGoldVnum).GetNameWithAmount((equip ? obj->get_rent_on() * count : obj->get_rent_off()) * factor * count, grammar::ECase::kNom).c_str(), - bf, OBJN(obj, ch, grammar::ECase::kAcc), count > 1 ? bf2 : "", recursive ? "" : kColorNrm); - act(buf, false, recep, 0, ch, kToVict); + if (!obj) { + return; } + // Строка собирается через fmt, а не в буфер на 256 байт: в неё входит название предмета в + // винительном падеже, а в UTF-8 русский текст занимает вдвое больше места -- длинного имени + // вместе с ценой и валютой хватало, чтобы вылезти за край (тот же класс, что #3751 и #3752). + std::string bf; + if (CAN_WEAR_ANY(obj)) { + bf = equip + ? fmt::format(" ({} если снять)", obj->get_rent_off() * factor * count) + : fmt::format(" ({} если надеть)", obj->get_rent_on() * factor * count); + } + const std::string bf2 = count > 1 ? fmt::format(" [{}]", count) : std::string(); + const int cost = (equip ? obj->get_rent_on() * count : obj->get_rent_off()) * factor * count; + + const std::string line = fmt::format("{} - {} {}{} за {}{} {}", + recursive ? "" : kColorWht, + cost, + MUD::Currency(currencies::kGoldVnum).GetNameWithAmount(cost, grammar::ECase::kNom), + bf, OBJN(obj, ch, grammar::ECase::kAcc), bf2, recursive ? "" : kColorNrm); + act(line.c_str(), false, recep, 0, ch, kToVict); } // end by WorM diff --git a/src/engine/db/player_index.cpp b/src/engine/db/player_index.cpp index 349ba6d866..0e15d3599d 100644 --- a/src/engine/db/player_index.cpp +++ b/src/engine/db/player_index.cpp @@ -7,6 +7,7 @@ */ #include "player_index.h" +#include "utils/native_text.h" #include "administration/accounts.h" #include "global_objects.h" @@ -103,24 +104,24 @@ std::size_t PlayersIndex::hasher::operator()(const std::string &value) const { // FNV-1a implementation using p = fnv_params; std::size_t result = p::offset_basis; - for (unsigned char c : value) { - result ^= static_cast(LOWER(c)); + // Регистр сворачивается ПО СИМВОЛУ, а не по байту (issue #3681): побайтная свёртка + // оставляет многобайтные буквы нетронутыми, из-за чего "Дрегвий" и "дрегвий" дают разные + // хэши и точный поиск персонажа промахивается. Компаратор ниже обязан согласоваться. + std::string folded = value; + native_text::to_lower(folded); + for (unsigned char c : folded) { + result ^= static_cast(c); result *= p::prime; } return result; } bool PlayersIndex::equal_to::operator()(const std::string &left, const std::string &right) const { - if (left.size() != right.size()) { + // Посимвольное сравнение без учёта регистра -- в паре к hasher выше. + if (native_text::compare_ci(left, right) != 0) { return false; } - for (std::size_t i = 0; i < left.size(); ++i) { - if (LOWER(left[i]) != LOWER(right[i])) { - return false; - } - } - return true; } @@ -129,13 +130,15 @@ bool PlayersIndex::equal_to::operator()(const std::string &left, const std::stri bool IsPlayerExists(const long id) { return player_table.IsPlayerExists(id); } long CmpPtableByName(char *name, int len) { - len = std::min(len, static_cast(strlen(name))); + // len -- в символах: вызывающие передают kMinNameLength, то есть «столько букв». + len = std::min(len, static_cast(native_text::char_count(name))); one_argument(name, arg); /* Anton Gorev (2015/12/29): I am not sure but I guess that linear search is not the best solution here. * TODO: make map helper (MAPHELPER). */ for (std::size_t i = 0; i < player_table.size(); i++) { std::string_view pname = player_table[i].name(); - if (!strn_cmp(pname.data(), arg, std::min(len, static_cast(pname.length())))) { + if (utils::IsSamePrefix(pname.data(), arg, + std::min(len, native_text::char_count(pname)))) { return static_cast(i); } } @@ -258,7 +261,11 @@ void ActualizePlayersIndex(char *name) { int deleted; char filename[kMaxStringLength]; - for (int i = 0; (name[i] = LOWER(name[i])); i++); + // По символу, а не по байту (issue #3681). Побайтная свёртка бьёт многобайтные буквы: + // у "г" второй байт UTF-8 равен 0xB3, а в KOI8-R это "Ё", и таблица опускала его в 0xA3 -- + // буква превращалась в "У". Имя файла получалось неверным, персонаж не загружался и просто + // не попадал в индекс (в боевом мире так терялось 1278 из 9117 записей). + native_text::to_lower(name); if (get_filename(name, filename, kPlayersFile)) { Player t_short_ch; Player *short_ch = &t_short_ch; @@ -346,6 +353,13 @@ void BuildPlayerIndexNew() { if (sscanf(name, "%s ", playername) == 0) continue; + // players.lst читается обычным fopen, минуя FBFILE, поэтому границу кодировки надо + // пройти здесь: иначе индекс останется в KOI8-R, а поиск пойдёт по нативной кодировке + // и ни одного существующего персонажа не найдёт (issue #3681). + const std::string native_name = native_text::from_disk_line(playername); + strncpy(playername, native_name.c_str(), sizeof(playername) - 1); + playername[sizeof(playername) - 1] = '\0'; + if (!player_table.IsPlayerExists(playername)) { ActualizePlayersIndex(playername); } @@ -372,8 +386,10 @@ void FlushPlayerIndex() { } ++saved; + // Имя уходит на диск в кодировке мира (сейчас KOI8-R) -- зеркало from_disk_line, + // которым индекс читается обратно (issue #3681). sprintf(name, "%s %ld %d %d\n", - i.name().c_str(), + native_text::to_disk(i.name()).c_str(), i.uid(), i.level, i.last_logon); fputs(name, players); } diff --git a/src/engine/db/sqlite_world_data_source.cpp b/src/engine/db/sqlite_world_data_source.cpp index bce8f4ca02..9cb9da9b4a 100644 --- a/src/engine/db/sqlite_world_data_source.cpp +++ b/src/engine/db/sqlite_world_data_source.cpp @@ -4,6 +4,7 @@ #ifdef HAVE_SQLITE #include "sqlite_world_data_source.h" +#include "utils/native_text.h" #include "utils/utils_encoding.h" #include "db.h" #include "obj_prototypes.h" @@ -1254,7 +1255,7 @@ std::vector SqliteWorldDataSource::LoadRooms(const std::vector auto room = new RoomData; room->vnum = vnum; // Apply UPPER to first character (same as Legacy loader) - if (!name.empty()) { name[0] = UPPER(name[0]); } + if (!name.empty()) { native_text::capitalize_first(name); } room->set_name(name); if (!description.empty()) diff --git a/src/engine/db/sqlite_world_data_source_freshness.cpp b/src/engine/db/sqlite_world_data_source_freshness.cpp index e8fc6efaeb..4a34db5d5d 100644 --- a/src/engine/db/sqlite_world_data_source_freshness.cpp +++ b/src/engine/db/sqlite_world_data_source_freshness.cpp @@ -25,11 +25,10 @@ void BindTextKoi(sqlite3_stmt *stmt, int col, const char *koi8) sqlite3_bind_null(stmt, col); return; } - const std::string in(koi8); - // koi_to_utf8 can roughly double the byte count; size generously. - std::vector out(in.size() * 2 + 4, '\0'); - codepages::koi_to_utf8(const_cast(in.c_str()), out.data()); - sqlite3_bind_text(stmt, col, out.data(), -1, SQLITE_TRANSIENT); + // Движок держит текст в UTF-8, потребитель тоже ждёт UTF-8 -- границы здесь больше нет. + // Перекодировка, оставшаяся с байтовых времён, теперь разбирала бы готовый UTF-8 как + // KOI8-R и удваивала каждую букву (issue #3681). + sqlite3_bind_text(stmt, col, koi8, -1, SQLITE_TRANSIENT); } void SqliteWorldDataSource::EnsureSyncTables() diff --git a/src/engine/db/world_data_source_base.cpp b/src/engine/db/world_data_source_base.cpp index 22bb0c16a1..32dd519371 100644 --- a/src/engine/db/world_data_source_base.cpp +++ b/src/engine/db/world_data_source_base.cpp @@ -1,6 +1,7 @@ // Part of Bylins http://www.mud.ru // Base class for world data sources - implementation +#include "utils/native_text.h" #include "world_data_source_base.h" #include "db.h" #include "obj_prototypes.h" @@ -57,7 +58,9 @@ void WorldDataSourceBase::ParseTriggerScript(Trigger *trig, const std::string &s // lowercase the command (first word) for faster comparison at runtime auto it = cmd->cmd.begin(); while (it != cmd->cmd.end() && (*it == ' ' || *it == '\t')) ++it; - while (it != cmd->cmd.end() && *it != ' ') { *it = LOWER(*it); ++it; } + for (; it != cmd->cmd.end() && *it != ' '; it += native_text::char_bytes(&*it)) { + native_text::copy_lower_char(&*it, &*it); + } if (!head) { diff --git a/src/engine/db/yaml_world_data_source.cpp b/src/engine/db/yaml_world_data_source.cpp index 7fa14f4629..a5ce84aadb 100644 --- a/src/engine/db/yaml_world_data_source.cpp +++ b/src/engine/db/yaml_world_data_source.cpp @@ -4,6 +4,7 @@ #ifdef HAVE_YAML #include "yaml_world_data_source.h" +#include "utils/native_text.h" #include "utils/utils_encoding.h" #include "dictionary_loader.h" #include "db.h" @@ -730,8 +731,12 @@ std::string YamlWorldDataSource::GetText(const YAML::Node &node, const std::stri { if (node[key]) { - // YAML files are already in KOI8-R, no conversion needed - std::string text = node[key].as(); + // Файлы мира лежат на диске в KOI8-R; переводим в нативную кодировку движка + // (под KOI8-R это тождество, под UTF-8 - перекодировка). Issue #3681. + // from_disk_text, а не from_koi8: он распознаёт уже-нативный текст и не перекодирует + // его повторно, поэтому зона, которую предыдущая сборка успела записать в UTF-8, + // читается как есть, а не превращается в кракозябры. + std::string text = native_text::from_disk_text(node[key].as()); // Convert line endings if configured for DOS format if (m_convert_lf_to_crlf) { @@ -1419,7 +1424,7 @@ RoomData* YamlWorldDataSource::ParseRoomNode(const YAML::Node &root, int vnum, i room->zone_rn = zone_rnum; std::string name = GetText(root, "name", "Untitled Room"); - if (!name.empty()) { name[0] = UPPER(name[0]); } + if (!name.empty()) { native_text::capitalize_first(name); } room->set_name(name); std::string description = GetText(root, "description", ""); @@ -2738,14 +2743,18 @@ bool YamlWorldDataSource::WriteYamlAtomic(const std::string &filepath, const YAM YAML::Emitter emitter; emitter << node; - std::ofstream out(temp_filepath); - if (!out.is_open()) + // Файл мира на диске -- KOI8-R, а движок держит текст в нативной кодировке. Собираем + // в память и перекодируем один раз при записи через native_text::write_file: иначе + // первое же сохранение (OLC или очередь "Reboot saving" из ConvertObjValues) молча + // переписывает зону в UTF-8, а загрузка прогоняет её через from_koi8 второй раз -- + // и мир превращается в кракозябры (issue #3681). + std::ostringstream out; + out << emitter.c_str(); + if (!native_text::write_file(temp_filepath, out.str())) { log("SYSERR: Failed to open temp file for writing: %s", temp_filepath.c_str()); return false; } - out << emitter.c_str(); - out.close(); std::rename(temp_filepath.c_str(), filepath.c_str()); return true; @@ -2768,12 +2777,7 @@ bool YamlWorldDataSource::WriteIndexYaml(const std::string &filepath, fs::create_directories(fs::path(filepath).parent_path()); std::string temp_filepath = filepath + ".tmp"; - std::ofstream out(temp_filepath); - if (!out.is_open()) - { - log("SYSERR: Failed to open temp file for index: %s", temp_filepath.c_str()); - return false; - } + std::ostringstream out; // Match the layout the Python converter emits and the loader expects: // : // - @@ -2790,7 +2794,11 @@ bool YamlWorldDataSource::WriteIndexYaml(const std::string &filepath, out << "- " << v << "\n"; } } - out.close(); + if (!native_text::write_file(temp_filepath, out.str())) + { + log("SYSERR: Failed to open temp file for index: %s", temp_filepath.c_str()); + return false; + } std::rename(temp_filepath.c_str(), filepath.c_str()); return true; } @@ -2876,12 +2884,7 @@ void YamlWorldDataSource::SaveZone(int zone_rnum) fs::create_directories(zone_dir); } - std::ofstream out(temp_file); - if (!out.is_open()) - { - log("SYSERR: Failed to open temp file for writing: %s", temp_file.c_str()); - return; - } + std::ostringstream out; Koi8rYamlEmitter yaml(out); @@ -3188,7 +3191,11 @@ void YamlWorldDataSource::SaveZone(int zone_rnum) yaml.DecreaseIndent(); } - out.close(); + if (!native_text::write_file(temp_file, out.str())) + { + log("SYSERR: Failed to open temp file for writing: %s", temp_file.c_str()); + return; + } std::rename(temp_file.c_str(), zone_file.c_str()); log("Saved zone %d to YAML file", zone.vnum); @@ -3363,12 +3370,7 @@ bool YamlWorldDataSource::SaveTriggers(int zone_rnum, int specific_vnum, int not // from the in-memory prototypes (which already reflect the edit). const std::string flat_path = m_world_dir + "/zones/" + std::to_string(zone.vnum) + "/triggers.yaml"; const std::string temp_file = flat_path + ".tmp"; - std::ofstream out(temp_file); - if (!out.is_open()) - { - log("SYSERR: Failed to open %s for writing", temp_file.c_str()); - return false; - } + std::ostringstream out; Koi8rYamlEmitter yaml(out); yaml.Comment("Triggers for zone " + std::to_string(zone.vnum)); for (const auto &[trig_vnum, trig] : entries) @@ -3380,7 +3382,11 @@ bool YamlWorldDataSource::SaveTriggers(int zone_rnum, int specific_vnum, int not EmitTriggerBody(yaml, trig); yaml.DecreaseIndent(); } - out.close(); + if (!native_text::write_file(temp_file, out.str())) + { + log("SYSERR: Failed to open %s for writing", temp_file.c_str()); + return false; + } if (std::rename(temp_file.c_str(), flat_path.c_str()) != 0) { log("SYSERR: Failed to rename %s to %s", temp_file.c_str(), flat_path.c_str()); @@ -3407,19 +3413,18 @@ bool YamlWorldDataSource::SaveTriggers(int zone_rnum, int specific_vnum, int not trig_file_ss << trig_dir << "/" << std::setfill('0') << std::setw(2) << rel_num << ".yaml"; const std::string trig_file = trig_file_ss.str(); const std::string temp_file = trig_file + ".tmp"; - std::ofstream out(temp_file); - if (!out.is_open()) - { - log("SYSERR: Failed to open %s for writing", temp_file.c_str()); - continue; - } + std::ostringstream out; Koi8rYamlEmitter yaml(out); yaml.Comment("Trigger #" + std::to_string(trig_vnum)); yaml.EmptyLine(); EmitTriggerBody(yaml, trig); - out.close(); + if (!native_text::write_file(temp_file, out.str())) + { + log("SYSERR: Failed to open %s for writing", temp_file.c_str()); + continue; + } if (std::rename(temp_file.c_str(), trig_file.c_str()) != 0) { log("SYSERR: Failed to rename %s to %s", temp_file.c_str(), trig_file.c_str()); @@ -3671,12 +3676,7 @@ void YamlWorldDataSource::SaveRooms(int zone_rnum, int specific_vnum) { const std::string flat_path = m_world_dir + "/zones/" + std::to_string(zone.vnum) + "/rooms.yaml"; const std::string temp_file = flat_path + ".tmp"; - std::ofstream out(temp_file); - if (!out.is_open()) - { - log("SYSERR: Failed to open %s for writing", temp_file.c_str()); - return; - } + std::ostringstream out; Koi8rYamlEmitter yaml(out); yaml.Comment("Rooms for zone " + std::to_string(zone.vnum)); for (const auto &[vnum, room] : entries) @@ -3688,7 +3688,11 @@ void YamlWorldDataSource::SaveRooms(int zone_rnum, int specific_vnum) EmitRoomBody(yaml, out, room); yaml.DecreaseIndent(); } - out.close(); + if (!native_text::write_file(temp_file, out.str())) + { + log("SYSERR: Failed to open %s for writing", temp_file.c_str()); + return; + } if (std::rename(temp_file.c_str(), flat_path.c_str()) != 0) { log("SYSERR: Failed to rename %s to %s", temp_file.c_str(), flat_path.c_str()); @@ -3713,19 +3717,18 @@ void YamlWorldDataSource::SaveRooms(int zone_rnum, int specific_vnum) int rel_num = vnum % 100; std::string room_file = rooms_dir + "/" + fmt::format("{:02d}", rel_num) + ".yaml"; std::string temp_file = room_file + ".tmp"; - std::ofstream out(temp_file); - if (!out.is_open()) - { - log("SYSERR: Failed to open %s for writing", temp_file.c_str()); - continue; - } + std::ostringstream out; Koi8rYamlEmitter yaml(out); yaml.Comment("Room #" + std::to_string(vnum)); yaml.EmptyLine(); EmitRoomBody(yaml, out, room); - out.close(); + if (!native_text::write_file(temp_file, out.str())) + { + log("SYSERR: Failed to open %s for writing", temp_file.c_str()); + continue; + } if (std::rename(temp_file.c_str(), room_file.c_str()) != 0) { log("SYSERR: Failed to rename %s to %s", temp_file.c_str(), room_file.c_str()); @@ -4320,12 +4323,7 @@ void YamlWorldDataSource::SaveMobs(int zone_rnum, int specific_vnum) { const std::string flat_path = m_world_dir + "/zones/" + std::to_string(zone.vnum) + "/mobs.yaml"; const std::string temp_file = flat_path + ".tmp"; - std::ofstream out(temp_file); - if (!out.is_open()) - { - log("SYSERR: Failed to open %s for writing", temp_file.c_str()); - return; - } + std::ostringstream out; Koi8rYamlEmitter yaml(out); yaml.Comment("Mobs for zone " + std::to_string(zone.vnum)); for (const auto &[vnum, mob] : entries) @@ -4337,7 +4335,11 @@ void YamlWorldDataSource::SaveMobs(int zone_rnum, int specific_vnum) EmitMobBody(yaml, out, *mob); yaml.DecreaseIndent(); } - out.close(); + if (!native_text::write_file(temp_file, out.str())) + { + log("SYSERR: Failed to open %s for writing", temp_file.c_str()); + return; + } if (std::rename(temp_file.c_str(), flat_path.c_str()) != 0) { log("SYSERR: Failed to rename %s to %s", temp_file.c_str(), flat_path.c_str()); @@ -4364,19 +4366,18 @@ void YamlWorldDataSource::SaveMobs(int zone_rnum, int specific_vnum) mob_file_ss << mobs_dir << "/" << std::setfill('0') << std::setw(2) << rel_num << ".yaml"; std::string mob_file = mob_file_ss.str(); std::string temp_file = mob_file + ".tmp"; - std::ofstream out(temp_file); - if (!out.is_open()) - { - log("SYSERR: Failed to open %s for writing", temp_file.c_str()); - continue; - } + std::ostringstream out; Koi8rYamlEmitter yaml(out); yaml.Comment("Mob #" + std::to_string(vnum)); yaml.EmptyLine(); EmitMobBody(yaml, out, *mob); - out.close(); + if (!native_text::write_file(temp_file, out.str())) + { + log("SYSERR: Failed to open %s for writing", temp_file.c_str()); + continue; + } if (std::rename(temp_file.c_str(), mob_file.c_str()) != 0) { log("SYSERR: Failed to rename %s to %s", temp_file.c_str(), mob_file.c_str()); @@ -4797,12 +4798,7 @@ void YamlWorldDataSource::SaveObjects(int zone_rnum, int specific_vnum) { const std::string flat_path = m_world_dir + "/zones/" + std::to_string(zone.vnum) + "/objects.yaml"; const std::string temp_file = flat_path + ".tmp"; - std::ofstream out(temp_file); - if (!out.is_open()) - { - log("SYSERR: Failed to open %s for writing", temp_file.c_str()); - return; - } + std::ostringstream out; Koi8rYamlEmitter yaml(out); yaml.Comment("Objects for zone " + std::to_string(zone.vnum)); for (const auto &[vnum, obj] : entries) @@ -4814,7 +4810,11 @@ void YamlWorldDataSource::SaveObjects(int zone_rnum, int specific_vnum) EmitObjectBody(yaml, out, obj); yaml.DecreaseIndent(); } - out.close(); + if (!native_text::write_file(temp_file, out.str())) + { + log("SYSERR: Failed to open %s for writing", temp_file.c_str()); + return; + } if (std::rename(temp_file.c_str(), flat_path.c_str()) != 0) { log("SYSERR: Failed to rename %s to %s", temp_file.c_str(), flat_path.c_str()); @@ -4841,19 +4841,18 @@ void YamlWorldDataSource::SaveObjects(int zone_rnum, int specific_vnum) obj_file_ss << objs_dir << "/" << std::setfill('0') << std::setw(2) << rel_num << ".yaml"; std::string obj_file = obj_file_ss.str(); std::string temp_file = obj_file + ".tmp"; - std::ofstream out(temp_file); - if (!out.is_open()) - { - log("SYSERR: Failed to open %s for writing", temp_file.c_str()); - continue; - } + std::ostringstream out; Koi8rYamlEmitter yaml(out); yaml.Comment("Object #" + std::to_string(vnum)); yaml.EmptyLine(); EmitObjectBody(yaml, out, obj); - out.close(); + if (!native_text::write_file(temp_file, out.str())) + { + log("SYSERR: Failed to open %s for writing", temp_file.c_str()); + continue; + } if (std::rename(temp_file.c_str(), obj_file.c_str()) != 0) { log("SYSERR: Failed to rename %s to %s", temp_file.c_str(), obj_file.c_str()); diff --git a/src/engine/entities/char_player.cpp b/src/engine/entities/char_player.cpp index 2841d26a3c..ebd7751e43 100644 --- a/src/engine/entities/char_player.cpp +++ b/src/engine/entities/char_player.cpp @@ -2,6 +2,7 @@ // Copyright (c) 2008 Krodo // Part of Bylins http://www.mud.ru +#include "utils/native_text.h" #include "char_player.h" #include "gameplay/core/experience.h" #include "administration/privilege.h" @@ -834,7 +835,9 @@ void Player::save_char(bool update_save_time) { // Накопленный буфер пишем на диск в бинарном режиме (байты файла == байты // буфера на всех платформах) и из него же считаем CRC -- без перечитывания // только что записанного файла. - const std::string &pfile = saved.str(); + // Граница записи: сейв уходит на диск в той же кодировке, в какой лежит остальной мир + // (сейчас KOI8-R), а не в нативной -- зеркало от from_disk_line на чтении (issue #3681). + const std::string pfile = native_text::to_disk(saved.str()); utils::CExecutionTimer wt; FILE *pf = fopen(filename, "wb"); if (!pf) { @@ -1865,7 +1868,7 @@ int Player::load_char_ascii(const char *name, const int load_flags) { // иначе в таблице crc будут пустые имена, т.к. сама плеер-таблица еще не сформирована // и в любом случае при ребуте это все пересчитывать не нужно if (!(load_flags & ELoadCharFlags::kNoCrcCheck)) { - FileCRC::verify_from_content(this->get_uid(), FileCRC::kPlayer, fl->buf, fl->size); + FileCRC::verify_from_content(this->get_uid(), FileCRC::kPlayer, fl->raw, fl->raw_size); } fbclose(fl); diff --git a/src/engine/network/admin_api.cpp b/src/engine/network/admin_api.cpp index 0ef231b0c2..72a7ee0147 100644 --- a/src/engine/network/admin_api.cpp +++ b/src/engine/network/admin_api.cpp @@ -31,38 +31,6 @@ using json = nlohmann::json; using namespace admin_api::handlers; -// ============================================================================ -// Helper functions for encoding conversion -// ============================================================================ - -// Convert KOI8-R string to UTF-8 for JSON -std::string koi8r_to_utf8(const std::string &koi8r) { - char utf8_buf[kMaxSockBuf * 6]; - char koi8r_buf[kMaxSockBuf * 6]; - - utf8_buf[0] = '\0'; - - strncpy(koi8r_buf, koi8r.c_str(), sizeof(koi8r_buf) - 1); - koi8r_buf[sizeof(koi8r_buf) - 1] = 0; - - codepages::koi_to_utf8(koi8r_buf, utf8_buf); - - return std::string(utf8_buf); -} - -// Convert UTF-8 string to KOI8-R (for incoming JSON data) -std::string utf8_to_koi8r(const std::string &utf8) { - char koi8r_buf[kMaxSockBuf * 6]; - char utf8_buf[kMaxSockBuf * 6]; - - strncpy(utf8_buf, utf8.c_str(), sizeof(utf8_buf) - 1); - utf8_buf[sizeof(utf8_buf) - 1] = '\0'; - - codepages::utf8_to_koi(utf8_buf, koi8r_buf); - - return std::string(koi8r_buf); -} - // ============================================================================ // Admin API socket I/O and chunking // ============================================================================ @@ -254,10 +222,9 @@ void admin_api_parse(DescriptorData *d, char *argument) { client_ip = d->host; } - // Convert username from UTF-8 to KOI8-R (server's internal encoding) - std::string username = utf8_to_koi8r(username_utf8); + // JSON is UTF-8 and so is the engine, so the name goes through as it is. + const std::string &username = username_utf8; - // Authenticate with KOI8-R username if (admin_api_authenticate(d, username.c_str(), password.c_str())) { json response; response["status"] = "ok"; diff --git a/src/engine/network/admin_api/crud_handlers.cpp b/src/engine/network/admin_api/crud_handlers.cpp index be279396eb..7f8c13cb52 100644 --- a/src/engine/network/admin_api/crud_handlers.cpp +++ b/src/engine/network/admin_api/crud_handlers.cpp @@ -180,11 +180,11 @@ void HandleListZones(DescriptorData* d) const ZoneData &zone = zone_table[zrn]; json zone_obj; zone_obj["vnum"] = zone.vnum; - zone_obj["name"] = admin_api::json::Koi8rToUtf8(zone.name); + zone_obj["name"] = zone.name; zone_obj["level"] = zone.level; if (!zone.author.empty()) { - zone_obj["author"] = admin_api::json::Koi8rToUtf8(zone.author); + zone_obj["author"] = zone.author; } response["zones"].push_back(zone_obj); } @@ -275,9 +275,9 @@ void HandleListMobs(DescriptorData* d, const char* zone_vnum_str) json mob_data; mob_data["vnum"] = mob_vnum; - mob_data["name"] = admin_api::json::Koi8rToUtf8(mob_proto[i].player_data.PNames[grammar::ECase::kNom]); - mob_data["aliases"] = admin_api::json::Koi8rToUtf8(mob_proto[i].get_npc_name()); - mob_data["short_desc"] = admin_api::json::Koi8rToUtf8(mob_proto[i].player_data.long_descr); + mob_data["name"] = mob_proto[i].player_data.PNames[grammar::ECase::kNom]; + mob_data["aliases"] = mob_proto[i].get_npc_name(); + mob_data["short_desc"] = mob_proto[i].player_data.long_descr; mob_data["level"] = mob_proto[i].GetLevel(); response["mobs"].push_back(mob_data); @@ -430,9 +430,9 @@ void HandleListObjects(DescriptorData* d, const char* zone_vnum_str) json obj_data; obj_data["vnum"] = obj->get_vnum(); - obj_data["name"] = admin_api::json::Koi8rToUtf8(obj->get_short_description()); - obj_data["aliases"] = admin_api::json::Koi8rToUtf8(obj->get_aliases()); - obj_data["short_desc"] = admin_api::json::Koi8rToUtf8(obj->get_short_description()); + obj_data["name"] = obj->get_short_description(); + obj_data["aliases"] = obj->get_aliases(); + obj_data["short_desc"] = obj->get_short_description(); obj_data["type"] = static_cast(obj->get_type()); response["objects"].push_back(obj_data); @@ -657,7 +657,7 @@ void HandleListRooms(DescriptorData* d, const char* zone_vnum_str) json room_obj; room_obj["vnum"] = world[rnum]->vnum; - room_obj["name"] = admin_api::json::Koi8rToUtf8(world[rnum]->name); + room_obj["name"] = world[rnum]->name; response["rooms"].push_back(room_obj); } @@ -876,7 +876,7 @@ void HandleListTriggers(DescriptorData* d, const char* zone_vnum_str) json trig_data; trig_data["vnum"] = trig_vnum; - trig_data["name"] = admin_api::json::Koi8rToUtf8(trig->get_name()); + trig_data["name"] = trig->get_name(); trig_data["attach_type"] = static_cast(trig->get_attach_type()); response["triggers"].push_back(trig_data); @@ -908,7 +908,6 @@ void HandleGetTrigger(DescriptorData* d, int trig_vnum) void HandleUpdateTrigger(DescriptorData* d, int trig_vnum, const char* json_data) { using admin_api::json::Utf8ToKoi8r; - using admin_api::json::Koi8rToUtf8; int rnum = find_trig_rnum(trig_vnum); if (rnum < 0 || !trig_index[rnum] || !trig_index[rnum]->proto) @@ -1000,7 +999,6 @@ void HandleUpdateTrigger(DescriptorData* d, int trig_vnum, const char* json_data void HandleCreateTrigger(DescriptorData* d, int zone_vnum, const char* json_data) { using admin_api::json::Utf8ToKoi8r; - using admin_api::json::Koi8rToUtf8; try { @@ -1110,7 +1108,7 @@ void HandleCreateTrigger(DescriptorData* d, int zone_vnum, const char* json_data std::string olc_output; if (temp_d->output && temp_d->bufptr > 0) { - olc_output = Koi8rToUtf8(std::string(temp_d->output, temp_d->bufptr)); + olc_output = std::string(temp_d->output, temp_d->bufptr); } // Clean up @@ -1197,10 +1195,10 @@ void HandleGetPlayers(DescriptorData* d) } json player; - player["name"] = admin_api::json::Koi8rToUtf8(ch->get_name().c_str()); + player["name"] = ch->get_name().c_str(); player["level"] = ch->GetLevel(); player["remort"] = ch->get_remort(); - player["class"] = admin_api::json::Koi8rToUtf8(MUD::Class(ch->GetClass()).GetName().c_str()); + player["class"] = MUD::Class(ch->GetClass()).GetName().c_str(); player["room"] = GET_ROOM_VNUM(ch->in_room); player["is_immortal"] = (ch->GetLevel() >= kLvlImmortal); @@ -1219,7 +1217,6 @@ void HandleGetPlayers(DescriptorData* d) // Helper: convert rnum args back to vnums for a single command static json SerializeZoneCommand(const reset_com &cmd, int index) { - using admin_api::json::Koi8rToUtf8; json obj; obj["index"] = index; @@ -1233,34 +1230,34 @@ static json SerializeZoneCommand(const reset_com &cmd, int index) obj["max_in_world"] = cmd.arg2; obj["room_vnum"] = world[cmd.arg3]->vnum; obj["max_in_room"] = cmd.arg4; - obj["comment"] = Koi8rToUtf8(mob_proto[cmd.arg1].get_npc_name()); + obj["comment"] = mob_proto[cmd.arg1].get_npc_name(); break; case 'O': obj["obj_vnum"] = obj_proto[cmd.arg1]->get_vnum(); obj["max_in_world"] = cmd.arg2; obj["room_vnum"] = world[cmd.arg3]->vnum; obj["load_percent"] = cmd.arg4; - obj["comment"] = Koi8rToUtf8(obj_proto[cmd.arg1]->get_short_description()); + obj["comment"] = obj_proto[cmd.arg1]->get_short_description(); break; case 'G': obj["obj_vnum"] = obj_proto[cmd.arg1]->get_vnum(); obj["max_in_world"] = cmd.arg2; obj["load_percent"] = cmd.arg4; - obj["comment"] = Koi8rToUtf8(obj_proto[cmd.arg1]->get_short_description()); + obj["comment"] = obj_proto[cmd.arg1]->get_short_description(); break; case 'E': obj["obj_vnum"] = obj_proto[cmd.arg1]->get_vnum(); obj["max_in_world"] = cmd.arg2; obj["eq_position"] = cmd.arg3; obj["load_percent"] = cmd.arg4; - obj["comment"] = Koi8rToUtf8(obj_proto[cmd.arg1]->get_short_description()); + obj["comment"] = obj_proto[cmd.arg1]->get_short_description(); break; case 'P': obj["obj_vnum"] = obj_proto[cmd.arg1]->get_vnum(); obj["max_in_world"] = cmd.arg2; obj["target_obj_vnum"] = obj_proto[cmd.arg3]->get_vnum(); obj["load_percent"] = cmd.arg4; - obj["comment"] = Koi8rToUtf8(obj_proto[cmd.arg1]->get_short_description()); + obj["comment"] = obj_proto[cmd.arg1]->get_short_description(); break; case 'D': obj["room_vnum"] = world[cmd.arg1]->vnum; @@ -1282,8 +1279,8 @@ static json SerializeZoneCommand(const reset_com &cmd, int index) case 'V': obj["trigger_type"] = cmd.arg1; obj["context"] = cmd.arg2; - if (cmd.sarg1) obj["var_name"] = Koi8rToUtf8(cmd.sarg1); - if (cmd.sarg2) obj["var_value"] = Koi8rToUtf8(cmd.sarg2); + if (cmd.sarg1) obj["var_name"] = cmd.sarg1; + if (cmd.sarg2) obj["var_value"] = cmd.sarg2; break; case 'F': obj["room_vnum"] = world[cmd.arg1]->vnum; @@ -1697,7 +1694,7 @@ void HandleResetZone(DescriptorData* d, int zone_vnum) response["status"] = "ok"; response["message"] = "Zone reset successfully"; response["zone_vnum"] = zone_vnum; - response["zone_name"] = admin_api::json::Koi8rToUtf8(zone_table[zrn].name); + response["zone_name"] = zone_table[zrn].name; SendJsonResponse(d, response); } diff --git a/src/engine/network/admin_api/json_helpers.cpp b/src/engine/network/admin_api/json_helpers.cpp index 8bc2100a81..915924b05d 100644 --- a/src/engine/network/admin_api/json_helpers.cpp +++ b/src/engine/network/admin_api/json_helpers.cpp @@ -36,33 +36,7 @@ json SerializeBitvector(Bitvector bits, size_t plane) // String Conversion (KOI8-R <-> UTF-8) // ============================================================================ -std::string Koi8rToUtf8(const char* koi8r) -{ - if (!koi8r) - { - return ""; - } - char utf8_buf[kMaxStringLength]; - codepages::koi_to_utf8(const_cast(koi8r), utf8_buf); - return std::string(utf8_buf); -} - -std::string Koi8rToUtf8(const std::string& koi8r) -{ - char utf8_buf[kMaxSockBuf * 6]; - char koi8r_buf[kMaxSockBuf * 6]; - - // Initialize buffers to prevent returning garbage if conversion fails - utf8_buf[0] = '\0'; - - strncpy(koi8r_buf, koi8r.c_str(), sizeof(koi8r_buf) - 1); - koi8r_buf[sizeof(koi8r_buf) - 1] = '\0'; - - codepages::koi_to_utf8(koi8r_buf, utf8_buf); - - return std::string(utf8_buf); -} std::string Utf8ToKoi8r(const std::string& utf8) { diff --git a/src/engine/network/admin_api/json_helpers.h b/src/engine/network/admin_api/json_helpers.h index 36f6d85a07..e084c926c8 100644 --- a/src/engine/network/admin_api/json_helpers.h +++ b/src/engine/network/admin_api/json_helpers.h @@ -209,8 +209,6 @@ inline std::optional ParseNested(const json& j, const char* key) * * Used when serializing entity data to JSON (which requires UTF-8) */ -std::string Koi8rToUtf8(const char* koi8r); -std::string Koi8rToUtf8(const std::string& koi8r); /** * \brief Convert UTF-8 string to KOI8-R for game data diff --git a/src/engine/network/admin_api/serializers.cpp b/src/engine/network/admin_api/serializers.cpp index 0623aa9c74..ec1b86eb75 100644 --- a/src/engine/network/admin_api/serializers.cpp +++ b/src/engine/network/admin_api/serializers.cpp @@ -29,19 +29,19 @@ json SerializeMob(const CharData& mob, int vnum) // Names (all 6 Russian cases + aliases) json names; - names["aliases"] = Koi8rToUtf8(mob.get_npc_name()); - names["nominative"] = Koi8rToUtf8(mob.player_data.PNames[grammar::ECase::kNom]); - names["genitive"] = Koi8rToUtf8(mob.player_data.PNames[grammar::ECase::kGen]); - names["dative"] = Koi8rToUtf8(mob.player_data.PNames[grammar::ECase::kDat]); - names["accusative"] = Koi8rToUtf8(mob.player_data.PNames[grammar::ECase::kAcc]); - names["instrumental"] = Koi8rToUtf8(mob.player_data.PNames[grammar::ECase::kIns]); - names["prepositional"] = Koi8rToUtf8(mob.player_data.PNames[grammar::ECase::kPre]); + names["aliases"] = mob.get_npc_name(); + names["nominative"] = mob.player_data.PNames[grammar::ECase::kNom]; + names["genitive"] = mob.player_data.PNames[grammar::ECase::kGen]; + names["dative"] = mob.player_data.PNames[grammar::ECase::kDat]; + names["accusative"] = mob.player_data.PNames[grammar::ECase::kAcc]; + names["instrumental"] = mob.player_data.PNames[grammar::ECase::kIns]; + names["prepositional"] = mob.player_data.PNames[grammar::ECase::kPre]; mob_obj["names"] = names; // Descriptions json descriptions; - descriptions["short_desc"] = Koi8rToUtf8(mob.player_data.long_descr); - descriptions["long_desc"] = Koi8rToUtf8(mob.player_data.description); + descriptions["short_desc"] = mob.player_data.long_descr; + descriptions["long_desc"] = mob.player_data.description; mob_obj["descriptions"] = descriptions; // Stats (level, HP, damage, etc.) @@ -190,10 +190,10 @@ json SerializeObject(const CObjectPrototype& obj, int vnum) { json obj_data; obj_data["vnum"] = vnum; - obj_data["aliases"] = Koi8rToUtf8(obj.get_aliases()); - obj_data["short_desc"] = Koi8rToUtf8(obj.get_short_description()); - obj_data["description"] = Koi8rToUtf8(obj.get_description()); - obj_data["action_desc"] = Koi8rToUtf8(obj.get_action_description()); + obj_data["aliases"] = obj.get_aliases(); + obj_data["short_desc"] = obj.get_short_description(); + obj_data["description"] = obj.get_description(); + obj_data["action_desc"] = obj.get_action_description(); obj_data["type"] = static_cast(obj.get_type()); obj_data["spec_param"] = obj.get_spec_param(); // symmetry: ParseObjectUpdate reads it @@ -241,7 +241,7 @@ json SerializeObject(const CObjectPrototype& obj, int vnum) case grammar::ECase::kPre: case_name = "prepositional"; break; default: continue; } - names[case_name] = Koi8rToUtf8(obj.get_PName(ecase)); + names[case_name] = obj.get_PName(ecase); } obj_data["names"] = names; @@ -284,8 +284,8 @@ json SerializeObject(const CObjectPrototype& obj, int vnum) for (const auto &ed : obj.get_ex_description()) { json extra; - extra["keywords"] = Koi8rToUtf8(ed.keyword); - extra["description"] = Koi8rToUtf8(ed.description); + extra["keywords"] = ed.keyword; + extra["description"] = ed.description; extra_descs.push_back(extra); } obj_data["extra_descriptions"] = extra_descs; @@ -309,7 +309,7 @@ json SerializeRoom(RoomData& room, int vnum) { json room_data; room_data["vnum"] = vnum; - room_data["name"] = Koi8rToUtf8(room.name); + room_data["name"] = room.name; // Description: temp_description holds a freshly-edited (unsaved) value; the // persisted description lives in the shared pool by description_num. Reading @@ -317,11 +317,11 @@ json SerializeRoom(RoomData& room, int vnum) // (issue #3401) -- fall back to the pooled text. if (room.temp_description) { - room_data["description"] = Koi8rToUtf8(room.temp_description); + room_data["description"] = room.temp_description; } else if (room.description_num > 0) { - room_data["description"] = Koi8rToUtf8(GlobalObjects::descriptions().get(room.description_num).c_str()); + room_data["description"] = GlobalObjects::descriptions().get(room.description_num).c_str(); } room_data["sector_type"] = static_cast(room.sector_type); @@ -347,11 +347,11 @@ json SerializeRoom(RoomData& room, int vnum) world[room.dir_option[dir]->to_room()]->vnum : -1; if (!room.dir_option[dir]->general_description.empty()) { - exit_obj["description"] = Koi8rToUtf8(room.dir_option[dir]->general_description); + exit_obj["description"] = room.dir_option[dir]->general_description; } if (room.dir_option[dir]->keyword) { - exit_obj["keyword"] = Koi8rToUtf8(room.dir_option[dir]->keyword); + exit_obj["keyword"] = room.dir_option[dir]->keyword; } // exit_info used to be a byte: a single 30-bit plane serialized as a plain number. // BitsetFlags keeps that flag identity, so the JSON stays a number (get_plane(0)). @@ -369,11 +369,11 @@ json SerializeRoom(RoomData& room, int vnum) json ed_obj; if (!ed.keyword.empty()) { - ed_obj["keyword"] = Koi8rToUtf8(ed.keyword); + ed_obj["keyword"] = ed.keyword; } if (!ed.description.empty()) { - ed_obj["description"] = Koi8rToUtf8(ed.description); + ed_obj["description"] = ed.description; } extra_descrs.push_back(ed_obj); } @@ -401,19 +401,19 @@ json SerializeZoneData(const ZoneData& zone, int vnum) { json zone_data; zone_data["vnum"] = vnum; - zone_data["name"] = Koi8rToUtf8(zone.name); + zone_data["name"] = zone.name; if (!zone.comment.empty()) { - zone_data["comment"] = Koi8rToUtf8(zone.comment); + zone_data["comment"] = zone.comment; } if (!zone.author.empty()) { - zone_data["author"] = Koi8rToUtf8(zone.author); + zone_data["author"] = zone.author; } if (!zone.location.empty()) { - zone_data["location"] = Koi8rToUtf8(zone.location); + zone_data["location"] = zone.location; } if (!zone.description.empty()) { - zone_data["description"] = Koi8rToUtf8(zone.description); + zone_data["description"] = zone.description; } zone_data["level"] = zone.level; @@ -453,7 +453,7 @@ json SerializeTrigger(const Trigger& trig, int vnum) { json trig_data; trig_data["vnum"] = vnum; - trig_data["name"] = Koi8rToUtf8(trig.get_name()); + trig_data["name"] = trig.get_name(); trig_data["attach_type"] = static_cast(trig.get_attach_type()); trig_data["trigger_type"] = trig.get_trigger_type(); trig_data["narg"] = trig.narg; @@ -463,12 +463,12 @@ json SerializeTrigger(const Trigger& trig, int vnum) // Argument if (!trig.arglist.empty()) { - trig_data["arglist"] = Koi8rToUtf8(trig.arglist); + trig_data["arglist"] = trig.arglist; } if (trig.get_script_language() == TriggerScriptLanguage::Lua) { - trig_data["script"] = Koi8rToUtf8(trig.get_lua_script_source()); + trig_data["script"] = trig.get_lua_script_source(); } else { @@ -479,7 +479,7 @@ json SerializeTrigger(const Trigger& trig, int vnum) auto cmd = *trig.cmdlist; while (cmd) { - commands.push_back(Koi8rToUtf8(cmd->cmd)); + commands.push_back(cmd->cmd); cmd = cmd->next; } } diff --git a/src/engine/network/descriptor_data.cpp b/src/engine/network/descriptor_data.cpp index ab69b11b61..454ade423c 100644 --- a/src/engine/network/descriptor_data.cpp +++ b/src/engine/network/descriptor_data.cpp @@ -6,6 +6,14 @@ */ #include "descriptor_data.h" + +#include +#include +#include "utils/logger.h" +#include "utils/utf8.h" +#include +#include +#include "utils/native_text.h" #include "utils/utils_encoding.h" #include "engine/entities/char_player.h" @@ -164,6 +172,40 @@ void DescriptorData::msdp_report_changed_vars() { } void DescriptorData::string_to_client_encoding(const char *in_str, char *out_str) const { + // Легаси-кодировки клиентов заданы таблицами "байт KOI8-R -> байт целевой кодировки", + // поэтому перед ними текст надо привести к KOI8-R. Под KOI8-R-рантаймом это тождество, + // под UTF-8 - настоящая перекодировка (issue #3681). Для UTF-8-клиента ничего приводить + // не нужно: см. case kCodePageUTF8 ниже. + // Зеркало предохранителя из to_disk. Всё нативное -- валидный UTF-8; если сюда пришло + // иное, значит текст прочитан с диска мимо границы и игрок увидит кашу. Так уже уезжали + // экран справки и список синонимов (issue #3681). + // + // Проверка -- полный разбор строки, быстрого выхода на латинице в is_valid нет. Замерено: + // 436 нс на строку в 208 байт, около 477 МБ/с. Для легаси-клиентов это заметно дешевле + // того, что тут и так делается (to_koi8 разбирает ту же строку, дальше побайтная таблица), + // а объёмы вывода мада от такой скорости далеки. + if (!utf8::is_valid(in_str)) { + static std::atomic seen{0}; + const unsigned long n = seen.fetch_add(1); + if (n < 10 || n % 10000 == 0) { + std::string head; + char byte[4]; + for (std::size_t i = 0; in_str[i] && i < 16; ++i) { + std::snprintf(byte, sizeof(byte), "%02x", static_cast(in_str[i])); + head += byte; + head += ' '; + } + log("SYSERR: клиенту уходит не-UTF-8 (#%lu) -- где-то пропущена граница чтения. " + "Первые байты: %s", n + 1, head.c_str()); + } + } + + std::string koi8_text; + if (keytable != kCodePageUTF8) { + koi8_text = native_text::to_koi8(in_str); + in_str = koi8_text.c_str(); + } + switch (keytable) { case kCodePageAlt: for (; *in_str; *out_str = codepages::KtoA(*in_str), in_str++, out_str++); @@ -204,7 +246,9 @@ void DescriptorData::string_to_client_encoding(const char *in_str, char *out_str // Anton Gorev (2016-04-25): we have to be careful. String in UTF-8 encoding may // contain character with code 0xff which telnet interprets as IAC. // II: FE and FF were never defined for any purpose in UTF-8, we are safe - codepages::koi_to_utf8(const_cast(in_str), out_str); + // Рантайм в UTF-8 - отдаём как есть. Перекодировка тут испортила бы текст + // (именно так и выглядела первая флип-сборка). + strcpy(out_str, in_str); break; default: diff --git a/src/engine/observability/event_sink.cpp b/src/engine/observability/event_sink.cpp index 4f0a4b641b..971a2baac5 100644 --- a/src/engine/observability/event_sink.cpp +++ b/src/engine/observability/event_sink.cpp @@ -52,13 +52,6 @@ void FlushAllSinks() { } } -std::string EngineStringToUtf8(const std::string& koi8r) { - std::array buf{}; - std::string mut = koi8r; // codepages::koi_to_utf8 takes char*, not const - mut.push_back('\0'); - codepages::koi_to_utf8(mut.data(), buf.data()); - return std::string(buf.data()); -} } // namespace observability diff --git a/src/engine/observability/event_sink.h b/src/engine/observability/event_sink.h index ebc99f2130..2d47949f65 100644 --- a/src/engine/observability/event_sink.h +++ b/src/engine/observability/event_sink.h @@ -51,11 +51,6 @@ bool HasAnyEventSink(); void EmitToAllSinks(const Event& ev); void FlushAllSinks(); -// Engine-side strings (character names, room descriptions, etc.) live in -// KOI8-R; nlohmann::json validates UTF-8 on serialization and rejects KOI8-R -// bytes. Emitters must convert text fields through this helper before putting -// them into Event.attrs. -std::string EngineStringToUtf8(const std::string& koi8r); } // namespace observability diff --git a/src/engine/observability/helpers.cpp b/src/engine/observability/helpers.cpp index ffd987ee41..3b8b40c011 100644 --- a/src/engine/observability/helpers.cpp +++ b/src/engine/observability/helpers.cpp @@ -1,5 +1,4 @@ #include "helpers.h" -#include "utils/utils_encoding.h" namespace observability { @@ -21,28 +20,6 @@ double ScopedMetric::elapsed_seconds() const { return m_timer.delta().count(); } -std::string koi8r_to_utf8(const std::string& input) { - if (input.empty()) { - return input; - } - // Fast path: ASCII-only strings need no conversion - bool has_high = false; - for (unsigned char c : input) { - if (c >= 128) { - has_high = true; - break; - } - } - if (!has_high) { - return input; - } - // Each KOI8-R byte expands to at most 3 UTF-8 bytes, plus null terminator - std::string output(input.size() * 3 + 1, '\0'); - codepages::koi_to_utf8(const_cast(input.c_str()), &output[0]); - output.resize(strlen(output.c_str())); - return output; -} - } // namespace observability // vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/engine/observability/helpers.h b/src/engine/observability/helpers.h index c2ff59b813..0f39b98bd4 100644 --- a/src/engine/observability/helpers.h +++ b/src/engine/observability/helpers.h @@ -34,13 +34,6 @@ class ScopedMetric { utils::CExecutionTimer m_timer; }; -/** - * Convert string from KOI8-R to UTF-8. - * Safe to call on ASCII strings (pass through unchanged). - * Used to sanitize all strings before sending to OTEL (protobuf requires UTF-8). - */ -std::string koi8r_to_utf8(const std::string& input); - using utils::NowTs; } // namespace observability diff --git a/src/engine/observability/log_sender.cpp b/src/engine/observability/log_sender.cpp index 265fb5277c..fb73087029 100644 --- a/src/engine/observability/log_sender.cpp +++ b/src/engine/observability/log_sender.cpp @@ -62,7 +62,7 @@ static void AddAttributesToLogRecord( baggage->GetAllEntries([&log_record](opentelemetry::nostd::string_view key, opentelemetry::nostd::string_view value) { std::string key_str(key.data(), key.size()); - std::string value_str(koi8r_to_utf8(std::string(value.data(), value.size()))); + std::string value_str(value.data(), value.size()); log_record->SetAttribute(key_str, value_str); return true; // continue iteration }); @@ -70,7 +70,7 @@ static void AddAttributesToLogRecord( // Add user attributes for (const auto& [key, value] : user_attributes) { - log_record->SetAttribute(key, koi8r_to_utf8(value)); + log_record->SetAttribute(key, value); } } @@ -99,7 +99,7 @@ static void LogWithLevel(logging::LogLevel level, // OTEL log records carry their own timestamp metadata. const auto sep = message.find(" :: "); const std::string body = (sep != std::string::npos) ? message.substr(sep + 4) : message; - log_record->SetBody(koi8r_to_utf8(body)); + log_record->SetBody(body); // Automatically add trace context + user attributes AddAttributesToLogRecord(log_record, attributes); diff --git a/src/engine/observability/metrics.cpp b/src/engine/observability/metrics.cpp index caf1bfdb17..204b726336 100644 --- a/src/engine/observability/metrics.cpp +++ b/src/engine/observability/metrics.cpp @@ -16,17 +16,6 @@ namespace observability { #ifdef WITH_OTEL static std::unordered_map>> histogram_cache; -// Convert all string attribute values from KOI8-R to UTF-8 at the API boundary. -// Callers MUST pass raw KOI8-R strings -- do NOT call koi8r_to_utf8() before passing attrs here, -// that would cause double-conversion and produce garbage. -// NOTE: ISpan::SetAttribute(string) also auto-converts -- same rule applies there. -static std::map ToUtf8Attrs(const std::map& attrs) { - std::map result; - for (const auto& [k, v] : attrs) { - result[k] = koi8r_to_utf8(v); - } - return result; -} #endif void OtelMetrics::RecordCounter(const std::string& name, int64_t value) { @@ -54,7 +43,7 @@ void OtelMetrics::RecordCounter(const std::string& name, int64_t value, if (meter) { if (value >= 0) { auto counter = meter->CreateUInt64Counter(name); - counter->Add(static_cast(value), ToUtf8Attrs(attributes)); + counter->Add(static_cast(value), attributes); } } } @@ -98,7 +87,7 @@ void OtelMetrics::RecordHistogram(const std::string& name, double value, histogram = it->second.get(); } auto context = opentelemetry::context::Context{}; - histogram->Record(value, ToUtf8Attrs(attributes), context); + histogram->Record(value, attributes, context); } } #else @@ -133,7 +122,7 @@ void OtelMetrics::RecordGauge(const std::string& name, double value, if (meter) { auto histogram = meter->CreateDoubleHistogram(name + ".gauge"); auto context = opentelemetry::context::Context{}; - histogram->Record(value, ToUtf8Attrs(attributes), context); + histogram->Record(value, attributes, context); } } #else diff --git a/src/engine/observability/trace_sender.cpp b/src/engine/observability/trace_sender.cpp index b764c5a541..fc29d95f81 100644 --- a/src/engine/observability/trace_sender.cpp +++ b/src/engine/observability/trace_sender.cpp @@ -36,13 +36,13 @@ void OtelSpan::End() { void OtelSpan::AddEvent(const std::string& name) { if (m_span) { - m_span->AddEvent(observability::koi8r_to_utf8(name)); + m_span->AddEvent(name); } } void OtelSpan::SetAttribute(const std::string& key, const std::string& value) { if (m_span) { - m_span->SetAttribute(key, observability::koi8r_to_utf8(value)); + m_span->SetAttribute(key, value); } } @@ -73,7 +73,7 @@ std::unique_ptr OtelTraceSender::StartSpan(const std::string& name) { if (observability::OtelProvider::Instance().IsEnabled()) { auto tracer = trace_api::Provider::GetTracerProvider()->GetTracer("bylins-tracer", "1.0.0"); if (tracer) { - auto span = tracer->StartSpan(observability::koi8r_to_utf8(name)); + auto span = tracer->StartSpan(name); return std::make_unique(span); } } @@ -96,7 +96,7 @@ std::unique_ptr OtelTraceSender::StartChildSpan( opentelemetry::trace::StartSpanOptions options; options.parent = otel_parent->GetContext(); - auto span = tracer->StartSpan(observability::koi8r_to_utf8(name), {}, options); + auto span = tracer->StartSpan(name, {}, options); return std::make_unique(span); } } diff --git a/src/engine/observability/traces.cpp b/src/engine/observability/traces.cpp index fd3d8268dc..7b231952c7 100644 --- a/src/engine/observability/traces.cpp +++ b/src/engine/observability/traces.cpp @@ -15,13 +15,13 @@ void Span::End() { void Span::AddEvent(const std::string& name) { if (m_span) { - m_span->AddEvent(koi8r_to_utf8(name)); + m_span->AddEvent(name); } } void Span::SetAttribute(const std::string& key, const std::string& value) { if (m_span) { - m_span->SetAttribute(key, koi8r_to_utf8(value)); + m_span->SetAttribute(key, value); } } @@ -41,7 +41,7 @@ Span OtelTraces::StartSpan(const std::string& name) { if (OtelProvider::Instance().IsEnabled()) { auto tracer = opentelemetry::trace::Provider::GetTracerProvider()->GetTracer("bylins-tracer", "1.0.0"); if (tracer) { - return Span(tracer->StartSpan(koi8r_to_utf8(name))); + return Span(tracer->StartSpan(name)); } } return Span(); @@ -52,9 +52,9 @@ Span OtelTraces::StartSpan(const std::string& name, if (OtelProvider::Instance().IsEnabled()) { auto tracer = opentelemetry::trace::Provider::GetTracerProvider()->GetTracer("bylins-tracer", "1.0.0"); if (tracer) { - auto span = tracer->StartSpan(koi8r_to_utf8(name)); + auto span = tracer->StartSpan(name); for (const auto& attr : attributes) { - span->SetAttribute(attr.first, koi8r_to_utf8(attr.second)); + span->SetAttribute(attr.first, attr.second); } return Span(span); } diff --git a/src/engine/olc/medit.cpp b/src/engine/olc/medit.cpp index 8b5abcc0e4..2f17d27fa6 100644 --- a/src/engine/olc/medit.cpp +++ b/src/engine/olc/medit.cpp @@ -8,6 +8,8 @@ ***************************************************************************/ #include "engine/db/world_characters.h" +#include "utils/native_text.h" +#include "utils/russian_keys.h" #include "gameplay/affects/affect_messages.h" #include "gameplay/fight/fight_messages.h" #include "engine/entities/obj_data.h" @@ -1289,11 +1291,11 @@ void medit_parse(DescriptorData *d, char *arg) { case MEDIT_CONFIRM_SAVESTRING: // * Ensure mob has MOB_ISNPC set or things will go pair shaped. OLC_MOB(d)->SetFlag(EMobFlag::kNpc); - switch (*arg) { + switch (native_text::first_char_code(arg)) { case 'y': case 'Y': - case 'д': - case 'Д': + case rus::kDe: + case rus::kDeUpper: // * Save the mob in memory and to disk. // SendMsgToChar("Saving mobile to memory a.\r\n", d->character.get()); medit_save_internally(d); @@ -1307,8 +1309,8 @@ void medit_parse(DescriptorData *d, char *arg) { case 'n': case 'N': - case 'н': - case 'Н': cleanup_olc(d, CLEANUP_ALL); + case rus::kEn: + case rus::kEnUpper: cleanup_olc(d, CLEANUP_ALL); break; default: SendMsgToChar("Неверный выбор!\r\n", d->character.get()); @@ -1320,7 +1322,7 @@ void medit_parse(DescriptorData *d, char *arg) { //------------------------------------------------------------------- case MEDIT_MAIN_MENU: i = 0; olc_log("%s command %c", GET_NAME(d->character), *arg); - switch (*arg) { + switch (native_text::first_char_code(arg)) { case 'q': case 'Q': if (OLC_VAL(d)) // Anything been changed? @@ -1536,118 +1538,118 @@ void medit_parse(DescriptorData *d, char *arg) { medit_disp_helpers(d); return; - case 'а': - case 'А': OLC_MODE(d) = MEDIT_SKILLS; + case rus::kA: + case rus::kAUpper: OLC_MODE(d) = MEDIT_SKILLS; medit_disp_skills(d); return; - case 'б': - case 'Б': OLC_MODE(d) = MEDIT_SPELLS; + case rus::kBe: + case rus::kBeUpper: OLC_MODE(d) = MEDIT_SPELLS; medit_disp_spells(d); return; - case 'в': - case 'В': OLC_MODE(d) = MEDIT_STR; + case rus::kVe: + case rus::kVeUpper: OLC_MODE(d) = MEDIT_STR; i++; break; - case 'г': - case 'Г': OLC_MODE(d) = MEDIT_DEX; + case rus::kGe: + case rus::kGeUpper: OLC_MODE(d) = MEDIT_DEX; i++; break; - case 'д': - case 'Д': OLC_MODE(d) = MEDIT_CON; + case rus::kDe: + case rus::kDeUpper: OLC_MODE(d) = MEDIT_CON; i++; break; - case 'е': - case 'Е': OLC_MODE(d) = MEDIT_WIS; + case rus::kIe: + case rus::kIeUpper: OLC_MODE(d) = MEDIT_WIS; i++; break; - case 'ж': - case 'Ж': OLC_MODE(d) = MEDIT_INT; + case rus::kZhe: + case rus::kZheUpper: OLC_MODE(d) = MEDIT_INT; i++; break; - case 'з': - case 'З': OLC_MODE(d) = MEDIT_CHA; + case rus::kZe: + case rus::kZeUpper: OLC_MODE(d) = MEDIT_CHA; i++; break; - case 'и': - case 'И': OLC_MODE(d) = MEDIT_HEIGHT; + case rus::kI: + case rus::kIUpper: OLC_MODE(d) = MEDIT_HEIGHT; i++; break; - case 'к': - case 'К': OLC_MODE(d) = MEDIT_WEIGHT; + case rus::kKa: + case rus::kKaUpper: OLC_MODE(d) = MEDIT_WEIGHT; i++; break; - case 'л': - case 'Л': OLC_MODE(d) = MEDIT_SIZE; + case rus::kEl: + case rus::kElUpper: OLC_MODE(d) = MEDIT_SIZE; i++; break; - case 'м': - case 'М': OLC_MODE(d) = MEDIT_EXTRA; + case rus::kEm: + case rus::kEmUpper: OLC_MODE(d) = MEDIT_EXTRA; i++; break; - case 'х': - case 'Х': OLC_MODE(d) = MEDIT_REMORT; + case rus::kHa: + case rus::kHaUpper: OLC_MODE(d) = MEDIT_REMORT; i++; break; - case 'Ю': - case 'ю': OLC_MODE(d) = MEDIT_MAXFACTOR; + case rus::kYuUpper: + case rus::kYu: OLC_MODE(d) = MEDIT_MAXFACTOR; i++; break; - case 'н': - case 'Н': SendMsgToChar(d->character.get(), "\r\nВведите новое значение от 0 до 100%% :"); + case rus::kEn: + case rus::kEnUpper: SendMsgToChar(d->character.get(), "\r\nВведите новое значение от 0 до 100%% :"); OLC_MODE(d) = MEDIT_LIKE; return; - case 'п': - case 'П': OLC_MODE(d) = MEDIT_DLIST_MENU; + case rus::kPe: + case rus::kPeUpper: OLC_MODE(d) = MEDIT_DLIST_MENU; disp_dl_list(d); return; - case 'р': - case 'Р': OLC_MODE(d) = MEDIT_ROLE; + case rus::kEr: + case rus::kErUpper: OLC_MODE(d) = MEDIT_ROLE; medit_disp_role(d); return; - case 'с': - case 'С': OLC_MODE(d) = MEDIT_RESISTANCES; + case rus::kEs: + case rus::kEsUpper: OLC_MODE(d) = MEDIT_RESISTANCES; medit_disp_resistances(d); return; - case 'т': - case 'Т': OLC_MODE(d) = MEDIT_SAVES; + case rus::kTe: + case rus::kTeUpper: OLC_MODE(d) = MEDIT_SAVES; medit_disp_saves(d); return; - case 'у': - case 'У': OLC_MODE(d) = MEDIT_ADD_PARAMETERS; + case rus::kU: + case rus::kUUpper: OLC_MODE(d) = MEDIT_ADD_PARAMETERS; medit_disp_add_parameters(d); return; - case 'ф': - case 'Ф': OLC_MODE(d) = MEDIT_FEATURES; + case rus::kEf: + case rus::kEfUpper: OLC_MODE(d) = MEDIT_FEATURES; medit_disp_features(d); return; - case 'ц': - case 'Ц': OLC_MODE(d) = MEDIT_RACE; + case rus::kTse: + case rus::kTseUpper: OLC_MODE(d) = MEDIT_RACE; medit_disp_race(d); return; - case 'ч': - case 'Ч': OLC_MODE(d) = MEDIT_CLONE; + case rus::kChe: + case rus::kCheUpper: OLC_MODE(d) = MEDIT_CLONE; medit_disp_clone_menu(d); return; @@ -2165,9 +2167,9 @@ void medit_parse(DescriptorData *d, char *arg) { case MEDIT_DLIST_MENU: if (*arg) { // Обрабатываем комнады добавить удалить и.т.п - switch (*arg) { - case 'а': - case 'А': + switch (native_text::first_char_code(arg)) { + case rus::kA: + case rus::kAUpper: // Добавляем запись. OLC_MODE(d) = MEDIT_DLIST_ADD; SendMsgToChar("\r\nVNUM - виртуальный номер прототипа\r\n" @@ -2186,8 +2188,8 @@ void medit_parse(DescriptorData *d, char *arg) { return; - case 'б': - case 'Б': + case rus::kBe: + case rus::kBeUpper: // Удаляем запись. OLC_MODE(d) = MEDIT_DLIST_DEL; SendMsgToChar("\r\nВведите номер удаляемой записи:", d->character.get()); diff --git a/src/engine/olc/oedit.cpp b/src/engine/olc/oedit.cpp index fd18a382d2..661979e38f 100644 --- a/src/engine/olc/oedit.cpp +++ b/src/engine/olc/oedit.cpp @@ -9,6 +9,8 @@ ************************************************************************/ #include "engine/db/world_objects.h" +#include "utils/russian_keys.h" +#include "utils/native_text.h" #include "gameplay/fight/fight_messages.h" #include "engine/db/obj_prototypes.h" #include "engine/core/conf.h" @@ -1478,11 +1480,11 @@ void oedit_parse(DescriptorData *d, char *arg) { switch (OLC_MODE(d)) { case OEDIT_CONFIRM_SAVESTRING: - switch (*arg) { + switch (native_text::first_char_code(arg)) { case 'y': case 'Y': - case 'д': - case 'Д': SendMsgToChar("Объект сохранен.\r\n", d->character.get()); + case rus::kDe: + case rus::kDeUpper: SendMsgToChar("Объект сохранен.\r\n", d->character.get()); OLC_OBJ(d)->remove_incorrect_values_keys(OLC_OBJ(d)->get_type()); oedit_save_internally(d); snprintf(buf, sizeof(buf), "OLC: %s edits obj %d", GET_NAME(d->character), OLC_NUM(d)); @@ -1493,8 +1495,8 @@ void oedit_parse(DescriptorData *d, char *arg) { case 'n': case 'N': - case 'н': - case 'Н': cleanup_olc(d, CLEANUP_ALL); + case rus::kEn: + case rus::kEnUpper: cleanup_olc(d, CLEANUP_ALL); break; default: SendMsgToChar("Неверный выбор!\r\n", d->character.get()); diff --git a/src/engine/olc/olc.cpp b/src/engine/olc/olc.cpp index c7bbfc0764..9709e56b61 100644 --- a/src/engine/olc/olc.cpp +++ b/src/engine/olc/olc.cpp @@ -185,11 +185,11 @@ void do_olc(CharData *ch, char *argument, int cmd, int subcmd) { return; } } else if (!a_isdigit(*buf1)) { - if (strn_cmp("save", buf1, 4) == 0 - || (lock = !strn_cmp("lock", buf1, 4)) == true - || (unlock = !strn_cmp("unlock", buf1, 6)) == true) { + if (utils::IsAbbr("save", buf1) + || (lock = utils::IsAbbr("lock", buf1)) == true + || (unlock = utils::IsAbbr("unlock", buf1)) == true) { // issue #3582: "save all" -- записать на диск все зоны данного типа. - if (strn_cmp("save", buf1, 4) == 0 && *buf2 && !str_cmp(buf2, "all")) { + if (utils::IsAbbr("save", buf1) && *buf2 && !str_cmp(buf2, "all")) { olc_save_all(ch, subcmd); return; } diff --git a/src/engine/olc/redit.cpp b/src/engine/olc/redit.cpp index f06b487ae5..85b6d093a9 100644 --- a/src/engine/olc/redit.cpp +++ b/src/engine/olc/redit.cpp @@ -9,6 +9,8 @@ ************************************************************************/ #include "engine/entities/obj_data.h" +#include "utils/russian_keys.h" +#include "utils/native_text.h" #include "engine/core/comm.h" #include "engine/db/db.h" #include "engine/db/world_data_source_manager.h" @@ -556,11 +558,11 @@ void redit_parse(DescriptorData *d, char *arg) { switch (OLC_MODE(d)) { case REDIT_CONFIRM_SAVESTRING: - switch (*arg) { + switch (native_text::first_char_code(arg)) { case 'y': case 'Y': - case 'д': - case 'Д': redit_save_internally(d); + case rus::kDe: + case rus::kDeUpper: redit_save_internally(d); snprintf(buf, sizeof(buf), "OLC: %s edits room %d.", GET_NAME(d->character), OLC_NUM(d)); olc_log("%s edit room %d", GET_NAME(d->character), OLC_NUM(d)); mudlog(buf, NRM, std::max(kLvlBuilder, GET_INVIS_LEV(d->character)), SYSLOG, true); @@ -571,8 +573,8 @@ void redit_parse(DescriptorData *d, char *arg) { case 'n': case 'N': - case 'н': - case 'Н': + case rus::kEn: + case rus::kEnUpper: // * Free everything up, including strings, etc. cleanup_olc(d, CLEANUP_ALL); break; @@ -677,8 +679,9 @@ void redit_parse(DescriptorData *d, char *arg) { case REDIT_NAME: if (OLC_ROOM(d)->name) free(OLC_ROOM(d)->name); - if (strlen(arg) > MAX_ROOM_NAME) - arg[MAX_ROOM_NAME - 1] = '\0'; + // Предел -- в символах, и режем по границе символа (issue #3681). + if (native_text::char_count(arg) > MAX_ROOM_NAME) + arg[native_text::char_offset(arg, MAX_ROOM_NAME - 1)] = '\0'; OLC_ROOM(d)->name = str_dup((arg && *arg) ? arg : "неопределено"); break; diff --git a/src/engine/olc/zedit.cpp b/src/engine/olc/zedit.cpp index c97386f1d9..1833ccff35 100644 --- a/src/engine/olc/zedit.cpp +++ b/src/engine/olc/zedit.cpp @@ -5,6 +5,8 @@ ************************************************************************/ #include "engine/db/obj_prototypes.h" +#include "utils/russian_keys.h" +#include "utils/native_text.h" #include "engine/entities/obj_data.h" #include "engine/core/comm.h" #include "engine/db/db.h" @@ -1362,11 +1364,11 @@ void zedit_parse(DescriptorData *d, char *arg) { switch (OLC_MODE(d)) { case ZEDIT_CONFIRM_SAVESTRING: - switch (*arg) { + switch (native_text::first_char_code(arg)) { case 'y': case 'Y': - case 'д': - case 'Д': + case rus::kDe: + case rus::kDeUpper: // * Save the zone in memory, hiding invisible people. SendMsgToChar("Зона сохранена.\r\n", d->character.get()); zedit_save_internally(d); @@ -1376,8 +1378,8 @@ void zedit_parse(DescriptorData *d, char *arg) { // FALL THROUGH case 'n': case 'N': - case 'н': - case 'Н': cleanup_olc(d, CLEANUP_ALL); + case rus::kEn: + case rus::kEnUpper: cleanup_olc(d, CLEANUP_ALL); break; default: SendMsgToChar("Неверный выбор!\r\n", d->character.get()); SendMsgToChar("Вы желаете сохранить зону? : ", d->character.get()); diff --git a/src/engine/scripting/dg_comm.cpp b/src/engine/scripting/dg_comm.cpp index 2e49f1ca9c..01335702a9 100644 --- a/src/engine/scripting/dg_comm.cpp +++ b/src/engine/scripting/dg_comm.cpp @@ -8,6 +8,7 @@ * $Revision$ * **************************************************************************/ +#include "utils/native_text.h" #include "engine/entities/obj_data.h" #include "utils/grammar/gender.h" #include "dg_scripts.h" @@ -31,10 +32,14 @@ char *any_one_name(char *argument, char *first_arg) { * Библиотечная функция ispunct() неправильно работает для русского языка * (по крайней мере у меня). Пока закоментировал. */ + // Шагаем по символу, а не по байту: под UTF-8 русская буква занимает два, и побайтовое + // приведение к нижнему регистру ломало её (issue #3681). for (arg = first_arg; *argument && !isspace(*argument) && - (!ispunct(*argument) || *argument == '#' || *argument == '-'); - arg++, argument++) - *arg = LOWER(*argument); + (!ispunct(*argument) || *argument == '#' || *argument == '-');) { + const std::size_t written = native_text::copy_lower_char(argument, arg); + arg += written; + argument += written; + } *arg = '\0'; return argument; diff --git a/src/engine/scripting/dg_db_scripts.cpp b/src/engine/scripting/dg_db_scripts.cpp index 069160cace..42711f084e 100644 --- a/src/engine/scripting/dg_db_scripts.cpp +++ b/src/engine/scripting/dg_db_scripts.cpp @@ -162,28 +162,28 @@ char *dirty_indent_trigger(char *cmd, int *level) { skip_spaces(&ptr); // ptr содержит строку без первых пробелов. - if (!strn_cmp("case ", ptr, 5) || !strn_cmp("default", ptr, 7)) { + if (utils::IsAbbr("case ", ptr) || utils::IsAbbr("default", ptr)) { // последовательные case (или default после case) без break if (!indent_stack.empty() - && !strn_cmp("case ", indent_stack.top().c_str(), 5)) { + && utils::IsAbbr("case ", indent_stack.top().c_str())) { --currlev; } else { indent_stack.push(ptr); } nextlev = currlev + 1; - } else if (!strn_cmp("if ", ptr, 3) || !strn_cmp("while ", ptr, 6) - || !strn_cmp("foreach ", ptr, 8) || !strn_cmp("switch ", ptr, 7)) { + } else if (utils::IsAbbr("if ", ptr) || utils::IsAbbr("while ", ptr) + || utils::IsAbbr("foreach ", ptr) || utils::IsAbbr("switch ", ptr)) { ++nextlev; indent_stack.push(ptr); - } else if (!strn_cmp("elseif ", ptr, 7) || !strn_cmp("else", ptr, 4)) { + } else if (utils::IsAbbr("elseif ", ptr) || utils::IsAbbr("else", ptr)) { --currlev; - } else if (!strn_cmp("break", ptr, 5) || !strn_cmp("end", ptr, 3) - || !strn_cmp("done", ptr, 4)) { + } else if (utils::IsAbbr("break", ptr) || utils::IsAbbr("end", ptr) + || utils::IsAbbr("done", ptr)) { // в switch завершающий break можно опускать и сразу писать done|end - if ((!strn_cmp("done", ptr, 4) || !strn_cmp("end", ptr, 3)) + if ((utils::IsAbbr("done", ptr) || utils::IsAbbr("end", ptr)) && !indent_stack.empty() - && (!strn_cmp("case ", indent_stack.top().c_str(), 5) - || !strn_cmp("default", indent_stack.top().c_str(), 7))) { + && (utils::IsAbbr("case ", indent_stack.top().c_str()) + || utils::IsAbbr("default", indent_stack.top().c_str()))) { --currlev; --nextlev; indent_stack.pop(); diff --git a/src/engine/scripting/dg_olc.cpp b/src/engine/scripting/dg_olc.cpp index 39c2a8c913..0cdaf5767d 100644 --- a/src/engine/scripting/dg_olc.cpp +++ b/src/engine/scripting/dg_olc.cpp @@ -14,6 +14,7 @@ * $Revision$ * **************************************************************************/ +#include "utils/native_text.h" #include "dg_olc.h" #include @@ -153,9 +154,8 @@ void TrigeditCompileDgCmdlist(Trigger *trig, const std::string &storage_str) while (it != str.end() && (*it == ' ' || *it == '\t')) { ++it; } - while (it != str.end() && *it != ' ') { - *it = LOWER(*it); - ++it; + for (; it != str.end() && *it != ' '; it += native_text::char_bytes(&*it)) { + native_text::copy_lower_char(&*it, &*it); } }; diff --git a/src/engine/scripting/dg_scripts.cpp b/src/engine/scripting/dg_scripts.cpp index 281323e51d..38c906ef87 100644 --- a/src/engine/scripting/dg_scripts.cpp +++ b/src/engine/scripting/dg_scripts.cpp @@ -31,6 +31,7 @@ #include "gameplay/mechanics/illumination.h" #include "gameplay/mechanics/inventory.h" #include "utils/utils_parse.h" +#include "utils/native_text.h" #include "dg_event.h" #include "engine/ui/color.h" #include "gameplay/clans/house.h" @@ -49,6 +50,7 @@ #include "engine/core/utils_char_obj.inl" #include "engine/core/target_resolver.h" #include "gameplay/mechanics/stable_objs.h" +#include #include #include #include "gameplay/mechanics/weather.h" @@ -135,55 +137,21 @@ void do_worldecho(char *msg); */ bool CharacterLinkDrop = false; -//table for replace UID_CHAR, UID_OBJ, UID_ROOM -const char uid_replace_table[] = { - '\x00', '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', '\x08', '\x09', '\x0a', '\x0b', '\x0c', '\x0d', - '\x0e', '\x0f', //16 - '\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17', '\x18', '\x19', '\x1a', '\x1b', '\x20', '\x20', - '\x20', '\x1f', //32 - '\x20', '\x21', '\x22', '\x23', '\x24', '\x25', '\x26', '\x27', '\x28', '\x29', '\x2a', '\x2b', '\x2c', '\x2d', - '\x2e', '\x2f', //48 - '\x30', '\x31', '\x32', '\x33', '\x34', '\x35', '\x36', '\x37', '\x38', '\x39', '\x3a', '\x3b', '\x3c', '\x3d', - '\x3e', '\x3f', //64 - '\x40', '\x41', '\x42', '\x43', '\x44', '\x45', '\x46', '\x47', '\x48', '\x49', '\x4a', '\x4b', '\x4c', '\x4d', - '\x4e', '\x4f', //80 - '\x50', '\x51', '\x52', '\x53', '\x54', '\x55', '\x56', '\x57', '\x58', '\x59', '\x5a', '\x5b', '\x5c', '\x5d', - '\x5e', '\x5f', //96 - '\x60', '\x61', '\x62', '\x63', '\x64', '\x65', '\x66', '\x67', '\x68', '\x69', '\x6a', '\x6b', '\x6c', '\x6d', - '\x6e', '\x6f', //112 - '\x70', '\x71', '\x72', '\x73', '\x74', '\x75', '\x76', '\x77', '\x78', '\x79', '\x7a', '\x7b', '\x7c', '\x7d', - '\x7e', '\x7f', //128 - '\x80', '\x81', '\x82', '\x83', '\x84', '\x85', '\x86', '\x87', '\x88', '\x89', '\x8a', '\x8b', '\x8c', '\x8d', - '\x8e', '\x8f', //144 - '\x90', '\x91', '\x92', '\x93', '\x94', '\x95', '\x96', '\x97', '\x98', '\x99', '\x9a', '\x9b', '\x9c', '\x9d', - '\x9e', '\x9f', //160 - '\xa0', '\xa1', '\xa2', '\xa3', '\xa4', '\xa5', '\xa6', '\xa7', '\xa8', '\xa9', '\xaa', '\xab', '\xac', '\xad', - '\xae', '\xaf', //176 - '\xb0', '\xb1', '\xb2', '\xb3', '\xb4', '\xb5', '\xb6', '\xb7', '\xb8', '\xb9', '\xba', '\xbb', '\xbc', '\xbd', - '\xbe', '\xbf', //192 - '\xc0', '\xc1', '\xc2', '\xc3', '\xc4', '\xc5', '\xc6', '\xc7', '\xc8', '\xc9', '\xca', '\xcb', '\xcc', '\xcd', - '\xce', '\xcf', //208 - '\xd0', '\xd1', '\xd2', '\xd3', '\xd4', '\xd5', '\xd6', '\xd7', '\xd8', '\xd9', '\xda', '\xdb', '\xdc', '\xdd', - '\xde', '\xdf', //224 - '\xe0', '\xe1', '\xe2', '\xe3', '\xe4', '\xe5', '\xe6', '\xe7', '\xe8', '\xe9', '\xea', '\xeb', '\xec', '\xed', - '\xee', '\xef', //240 - '\xf0', '\xf1', '\xf2', '\xf3', '\xf4', '\xf5', '\xf6', '\xf7', '\xf8', '\xf9', '\xfa', '\xfb', '\xfc', '\xfd', - '\xfe', '\xff' //256 -}; void script_log(const char *msg, LogMode type) { - char tmpbuf[kMaxStringLength]; - - snprintf(tmpbuf, kMaxStringLength, "SCRIPT LOG %s", msg); - - char *pos = tmpbuf; - while (*pos != '\0') { - *pos = uid_replace_table[static_cast(*pos)]; - ++pos; - } + std::string text = fmt::format("SCRIPT LOG {}", msg); + // Внутри движка ссылка на сущность хранится как <метка UID><номер> (см. dg_scripts.h). + // В лог сырые управляющие байты писать нельзя -- меняем метку на пробел, номер остаётся + // читаемым. Все четыре метки меньше 0x80, внутри многобайтовых UTF-8 последовательностей + // такие значения не встречаются, так что кириллица не задета. + std::replace_if(text.begin(), text.end(), + [](char c) { + return c == UID_OBJ || c == UID_ROOM || c == UID_CHAR || c == UID_CHAR_ALL; + }, + ' '); - log("%s", tmpbuf); - mudlog(tmpbuf, type ? type : NRM, kLvlBuilder, ERRLOG, true); + log("%s", text.c_str()); + mudlog(text, type ? type : NRM, kLvlBuilder, ERRLOG, true); } /* @@ -191,10 +159,9 @@ void script_log(const char *msg, LogMode type) { * Will eventually allow on-line view of script errors. */ void trig_log(Trigger *trig, std::string msg, LogMode type) { - char tmpbuf[kMaxStringLength]; - snprintf(tmpbuf, kMaxStringLength, "(Trigger: %s, VNum: %d) : %s [строка: %d]", GET_TRIG_NAME(trig), - GET_TRIG_VNUM(trig), msg.c_str(), last_trig_line_num); - script_log(tmpbuf, type); + script_log(fmt::format("(Trigger: {}, VNum: {}) : {} [строка: {}]", + GET_TRIG_NAME(trig), GET_TRIG_VNUM(trig), msg, last_trig_line_num).c_str(), + type); } cmdlist_element::shared_ptr find_end(Trigger *trig, cmdlist_element::shared_ptr cl); @@ -1581,7 +1548,7 @@ void find_replacement(void *go, ObjData *tmp_obj = nullptr, *obj = nullptr; RoomData *tmp_room = nullptr, *room = nullptr; std::string name; - int num = 0, count = 0, i; + int num = 0, count = 0; char uid_type = '\0'; char tmp[kMaxTrglineLength] = {}; const char *send_cmd[] = {"msend", "osend", "wsend"}; @@ -2426,8 +2393,8 @@ void find_replacement(void *go, } } else if (!str_cmp(field, "iname")) { if (*subfield) { - if (strlen(subfield) > MAX_MOB_NAME) - subfield[MAX_MOB_NAME - 1] = '\0'; + if (native_text::char_count(subfield) > MAX_MOB_NAME) + subfield[native_text::char_offset(subfield, MAX_MOB_NAME - 1)] = '\0'; mob->player_data.PNames[grammar::ECase::kNom] = subfield; } else @@ -2435,8 +2402,8 @@ void find_replacement(void *go, } else if (!str_cmp(field, "rname")) { if (*subfield) { - if (strlen(subfield) > MAX_MOB_NAME) - subfield[MAX_MOB_NAME - 1] = '\0'; + if (native_text::char_count(subfield) > MAX_MOB_NAME) + subfield[native_text::char_offset(subfield, MAX_MOB_NAME - 1)] = '\0'; mob->player_data.PNames[grammar::ECase::kGen] = subfield; } else @@ -2444,8 +2411,8 @@ void find_replacement(void *go, } else if (!str_cmp(field, "dname")) { if (*subfield) { - if (strlen(subfield) > MAX_MOB_NAME) - subfield[MAX_MOB_NAME - 1] = '\0'; + if (native_text::char_count(subfield) > MAX_MOB_NAME) + subfield[native_text::char_offset(subfield, MAX_MOB_NAME - 1)] = '\0'; mob->player_data.PNames[grammar::ECase::kDat] = subfield; } else @@ -2453,8 +2420,8 @@ void find_replacement(void *go, } else if (!str_cmp(field, "vname")) { if (*subfield) { - if (strlen(subfield) > MAX_MOB_NAME) - subfield[MAX_MOB_NAME - 1] = '\0'; + if (native_text::char_count(subfield) > MAX_MOB_NAME) + subfield[native_text::char_offset(subfield, MAX_MOB_NAME - 1)] = '\0'; mob->player_data.PNames[grammar::ECase::kAcc] = subfield; } else @@ -2462,8 +2429,8 @@ void find_replacement(void *go, } else if (!str_cmp(field, "tname")) { if (*subfield) { - if (strlen(subfield) > MAX_MOB_NAME) - subfield[MAX_MOB_NAME - 1] = '\0'; + if (native_text::char_count(subfield) > MAX_MOB_NAME) + subfield[native_text::char_offset(subfield, MAX_MOB_NAME - 1)] = '\0'; mob->player_data.PNames[grammar::ECase::kIns] = subfield; } else @@ -2471,8 +2438,8 @@ void find_replacement(void *go, } else if (!str_cmp(field, "pname")) { if (*subfield) { - if (strlen(subfield) > MAX_MOB_NAME) - subfield[MAX_MOB_NAME - 1] = '\0'; + if (native_text::char_count(subfield) > MAX_MOB_NAME) + subfield[native_text::char_offset(subfield, MAX_MOB_NAME - 1)] = '\0'; mob->player_data.PNames[grammar::ECase::kPre] = subfield; } else @@ -2480,8 +2447,8 @@ void find_replacement(void *go, } else if (!str_cmp(field, "name")) { if (*subfield) { - if (strlen(subfield) > MAX_MOB_NAME) - subfield[MAX_MOB_NAME - 1] = '\0'; + if (native_text::char_count(subfield) > MAX_MOB_NAME) + subfield[native_text::char_offset(subfield, MAX_MOB_NAME - 1)] = '\0'; mob->set_name(subfield); } else { @@ -2776,8 +2743,7 @@ void find_replacement(void *go, } else if (!str_cmp(field, "clan")) { if (CLAN(mob)) { snprintf(str, str_size, "%s", CLAN(mob)->GetAbbrev()); - for (i = 0; str[i]; i++) - str[i] = LOWER(str[i]); + native_text::to_lower(str); } else snprintf(str, str_size, "0"); } else if (!str_cmp(field, "ClanRank")) { @@ -3144,7 +3110,7 @@ void find_replacement(void *go, int num; int sum = 0; for (num = 0; num < EApply::kNumberApplies; num++) { - if (!strn_cmp(subfield, apply_types[num], strlen(subfield))) + if (utils::IsAbbr(subfield, apply_types[num])) break; } if (num == EApply::kNumberApplies) { @@ -3926,8 +3892,8 @@ void find_replacement(void *go, if (*subfield) { if (room->name) free(room->name); - if (strlen(subfield) > MAX_ROOM_NAME) - subfield[MAX_ROOM_NAME - 1] = '\0'; + if (native_text::char_count(subfield) > MAX_ROOM_NAME) + subfield[native_text::char_offset(subfield, MAX_ROOM_NAME - 1)] = '\0'; room->name = str_dup(subfield); } else snprintf(str, str_size, "%s", room->name); @@ -4379,8 +4345,13 @@ int eval_lhs_op_rhs(const char *expr, char *result, size_t result_size, void *go p = matching_paren(p) + 1; else if (*p == '"') p = matching_quote(p) + 1; - else if (a_isalnum(*p)) - for (p++; *p && (a_isalnum(*p) || isspace(*p)); p++); + // Step over whole characters (issue #3681): a byte-wise scan ends a token in the middle + // of a multibyte letter. isspace() takes an unsigned value -- a raw char is negative for + // any non-ASCII byte, which is undefined behaviour. + else if (native_text::is_alnum_char(p)) + for (p += native_text::char_bytes(p); + *p && (native_text::is_alnum_char(p) || isspace(static_cast(*p))); + p += native_text::char_bytes(p)); else p++; } @@ -4388,7 +4359,7 @@ int eval_lhs_op_rhs(const char *expr, char *result, size_t result_size, void *go for (i = 0; *ops[i] != '\n'; i++) for (j = 0; tokens[j]; j++) - if (!strn_cmp(ops[i], tokens[j], strlen(ops[i]))) { + if (utils::IsAbbr(ops[i], tokens[j])) { *tokens[j] = '\0'; p = tokens[j] + strlen(ops[i]); @@ -4757,7 +4728,7 @@ void process_wait(void *go, Trigger *trig, int type, char *cmd, const cmdlist_el if (!*arg) { snprintf(buf2, sizeof(buf2), "wait w/o an arg: '%s'", cl->cmd.c_str()); trig_log(trig, buf2); - } else if (!strn_cmp(arg, "until ", 6)) // valid forms of time are 14:30 and 1430 + } else if (utils::IsAbbr("until ", arg)) // valid forms of time are 14:30 and 1430 { if (sscanf(arg, "until %ld:%ld", &hr, &min) == 2) min += (hr * 60); @@ -6318,8 +6289,8 @@ void do_tlist(CharData *ch, char *argument, int cmd, int/* subcmd*/) { char trgtypes[256]; for (; nr < top_of_trigt && (trig_index[nr]->vnum <= last); nr++) { std::string out = ""; - snprintf(buf, sizeof(buf), "%2d) [%5d] %-50s ", ++found, - trig_index[nr]->vnum, trig_index[nr]->proto->get_name().c_str()); + strcpy(buf, fmt::format("{:2}) [{:5}] {:<50} ", ++found, + trig_index[nr]->vnum, trig_index[nr]->proto->get_name()).c_str()); out += buf; if (trig_index[nr]->proto->get_attach_type() == MOB_TRIGGER) { sprintbit(trig_index[nr]->proto->get_trigger_type(), trig_types, trgtypes, sizeof(trgtypes)); diff --git a/src/engine/scripting/dg_triggers.cpp b/src/engine/scripting/dg_triggers.cpp index f9dbb6b9a0..ef26c0b46a 100644 --- a/src/engine/scripting/dg_triggers.cpp +++ b/src/engine/scripting/dg_triggers.cpp @@ -1015,7 +1015,7 @@ int cmd_otrig(ObjData *obj, CharData *actor, char *cmd, const char *argument, in if (IS_SET(GET_TRIG_NARG(t), type) && (t->arglist[0] == '*' - || 0 == strn_cmp(t->arglist.c_str(), cmd, t->arglist.size()))) { + || utils::IsAbbr(t->arglist.c_str(), cmd))) { // Спящий игрок command-триггеры не запускает, но и команду у него не отбираем: // см. комментарий в command_mtrigger. if (!actor->IsNpc() && actor->GetPosition() == EPosition::kSleep) { diff --git a/src/engine/scripting/trigger_indenter.cpp b/src/engine/scripting/trigger_indenter.cpp index a055e352ec..26f8aaa340 100644 --- a/src/engine/scripting/trigger_indenter.cpp +++ b/src/engine/scripting/trigger_indenter.cpp @@ -24,26 +24,26 @@ char *TriggerIndenter::indent(char *cmd, int *level) { char *ptr = cmd; skip_spaces(&ptr); - if (!strn_cmp("case ", ptr, 5) || !strn_cmp("default", ptr, 7)) { + if (utils::IsAbbr("case ", ptr) || utils::IsAbbr("default", ptr)) { if (!indent_stack_.empty() - && !strn_cmp("case ", indent_stack_.top().c_str(), 5)) { + && utils::IsAbbr("case ", indent_stack_.top().c_str())) { --currlev; } else { indent_stack_.push(ptr); } nextlev = currlev + 1; - } else if (!strn_cmp("if ", ptr, 3) || !strn_cmp("while ", ptr, 6) - || !strn_cmp("foreach ", ptr, 8) || !strn_cmp("switch ", ptr, 7)) { + } else if (utils::IsAbbr("if ", ptr) || utils::IsAbbr("while ", ptr) + || utils::IsAbbr("foreach ", ptr) || utils::IsAbbr("switch ", ptr)) { ++nextlev; indent_stack_.push(ptr); - } else if (!strn_cmp("elseif ", ptr, 7) || !strn_cmp("else", ptr, 4)) { + } else if (utils::IsAbbr("elseif ", ptr) || utils::IsAbbr("else", ptr)) { --currlev; - } else if (!strn_cmp("break", ptr, 5) || !strn_cmp("end", ptr, 3) - || !strn_cmp("done", ptr, 4)) { - if ((!strn_cmp("done", ptr, 4) || !strn_cmp("end", ptr, 3)) + } else if (utils::IsAbbr("break", ptr) || utils::IsAbbr("end", ptr) + || utils::IsAbbr("done", ptr)) { + if ((utils::IsAbbr("done", ptr) || utils::IsAbbr("end", ptr)) && !indent_stack_.empty() - && (!strn_cmp("case ", indent_stack_.top().c_str(), 5) - || !strn_cmp("default", indent_stack_.top().c_str(), 7))) { + && (utils::IsAbbr("case ", indent_stack_.top().c_str()) + || utils::IsAbbr("default", indent_stack_.top().c_str()))) { --currlev; --nextlev; indent_stack_.pop(); diff --git a/src/engine/structs/flag_data.cpp b/src/engine/structs/flag_data.cpp index 76fb575dec..4b56fa3101 100644 --- a/src/engine/structs/flag_data.cpp +++ b/src/engine/structs/flag_data.cpp @@ -32,10 +32,6 @@ int ext_search_block(const char *arg, const char *const *const list, int exact) } } } else { - size_t l = strlen(arg); - if (!l) { - l = 1; // Avoid "" to match the first available string - } for (i = j = 0, o = 1; j != 1; i++) { if (**(list + i) == '\n') { o = 1; @@ -50,7 +46,7 @@ int ext_search_block(const char *arg, const char *const *const list, int exact) break; } } else { - if (!strn_cmp(arg, *(list + i), l)) { + if (utils::IsAbbr(arg, *(list + i))) { return j | o; } else { o <<= 1; diff --git a/src/engine/ui/alias.cpp b/src/engine/ui/alias.cpp index 5d43a9113e..071c8521e5 100644 --- a/src/engine/ui/alias.cpp +++ b/src/engine/ui/alias.cpp @@ -19,6 +19,7 @@ #include "engine/entities/char_data.h" #include "engine/ui/alias.h" +#include "utils/native_text.h" void WriteAliases(CharData *ch) { FILE *file; @@ -39,16 +40,19 @@ void WriteAliases(CharData *ch) { } for (temp = GET_ALIASES(ch); temp; temp = temp->next) { - size_t aliaslen = strlen(temp->alias); - size_t repllen = strlen(temp->replacement); + // Файл синонимов лежит на диске в кодировке мира, а движок держит текст в нативной. + // Длины в формате -- байтовые, поэтому считаем их уже по перекодированным строкам, + // иначе чтение разъедется на первом же русском синониме (issue #3681). + const std::string alias_on_disk = native_text::to_disk(temp->alias); + const std::string repl_on_disk = native_text::to_disk(temp->replacement); fprintf(file, "%d\n%s\n" // Alias "%d\n%s\n" // Replacement "%d\n", // Type - static_cast(aliaslen), - temp->alias, - static_cast(repllen), - temp->replacement, + static_cast(alias_on_disk.size()), + alias_on_disk.c_str(), + static_cast(repl_on_disk.size()), + repl_on_disk.c_str(), temp->type); } @@ -81,11 +85,13 @@ void ReadAliases(CharData *ch) { { dummyi = fscanf(file, "%d\n", &length); dummyc = fgets(xbuf, length + 1, file); - t2->alias = str_dup(xbuf); + // from_disk_line, а не from_koi8: он распознаёт уже нативный текст и не перекодирует + // повторно -- файлы, записанные сборкой без перекодировки, читаются как есть. + t2->alias = str_dup(native_text::from_disk_line(xbuf).c_str()); // Build the replacement. dummyi = fscanf(file, "%d\n", &length); dummyc = fgets(xbuf, length + 1, file); - t2->replacement = str_dup(xbuf); + t2->replacement = str_dup(native_text::from_disk_line(xbuf).c_str()); // Figure out the alias type. dummyi = fscanf(file, "%d\n", &length); t2->type = length; diff --git a/src/engine/ui/cmd/do_affects.cpp b/src/engine/ui/cmd/do_affects.cpp index aaadc3cba3..3a3696e3e9 100644 --- a/src/engine/ui/cmd/do_affects.cpp +++ b/src/engine/ui/cmd/do_affects.cpp @@ -2,6 +2,7 @@ // Created by Sventovit on 07.09.2024. // +#include #include "engine/entities/char_data.h" #include "gameplay/affects/affect_messages.h" #include "administration/privilege.h" @@ -12,8 +13,6 @@ #include "gameplay/mechanics/groups.h" #include "gameplay/affects/affect_data.h" -#include - #include #include #include @@ -58,9 +57,8 @@ struct SquashRow { void do_affects(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { char sp_name[kMaxStringLength]; - const size_t agr_length = strlen(argument); - if (*argument && !strn_cmp(argument, "краткий", agr_length)) { + if (*argument && utils::IsAbbr(argument, "краткий")) { if (!ch->get_master()) { group::print_one_line(ch, ch, true, 0); } else { @@ -72,7 +70,7 @@ void do_affects(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { // issue.affects-squash: "аффекты все" forces the full, un-collapsed per-source list. Mortals // otherwise get one row per distinct effect (duplicate sources collapsed, longest duration shown); // immortals always see every slot (with modifier/potency detail). - const bool show_all = (*argument && !strn_cmp(argument, "все", agr_length)); + const bool show_all = (*argument && utils::IsAbbr(argument, "все")); const bool squash = !privilege::IsImmortal(ch) && !show_all; // Show the bitset without "hiding" etc. @@ -118,6 +116,8 @@ void do_affects(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { } for (const auto &r : rows) { // A permanent source wins the label; otherwise show the longest remaining time. + // Ширина колонок -- в символах: fmt для корректного UTF-8 меряет её в кодовых + // точках, printf мерил бы в байтах (issue #3681). std::string line = fmt::format("{}{}{:<21} {:<12}{}", (!r.name.empty() && r.name[0] == '!') ? "Состояние : " : "Заклинание : ", kColorBoldCyn, r.name, @@ -148,9 +148,11 @@ void do_affects(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { snprintf(sp_name, sizeof(sp_name), "%s", affects::AffectMsg(aff->affect_type, affects::EAffectMsgType::kShortDesc).c_str()); const std::string duration = FormatAffectDuration(AffectDisplayMod(aff)); - snprintf(buf, kMaxStringLength, "%s%s%-21s %-12s%s ", - *sp_name == '!' ? "Состояние : " : "Заклинание : ", - kColorBoldCyn, sp_name, duration.c_str(), kColorNrm); + // Ширина колонок -- в символах: fmt для корректного UTF-8 меряет её в кодовых + // точках, printf мерил бы в байтах (issue #3681). + strcpy(buf, fmt::format("{}{}{:<21} {:<12}{} ", + *sp_name == '!' ? "Состояние : " : "Заклинание : ", + kColorBoldCyn, sp_name, duration, kColorNrm).c_str()); *buf2 = '\0'; if (immortal) { if (aff->modifier) { diff --git a/src/engine/ui/cmd/do_alias.cpp b/src/engine/ui/cmd/do_alias.cpp index de102d0e2f..c7421ee0e1 100644 --- a/src/engine/ui/cmd/do_alias.cpp +++ b/src/engine/ui/cmd/do_alias.cpp @@ -7,6 +7,7 @@ */ #include "engine/entities/char_data.h" +#include #include "engine/ui/alias.h" void do_alias(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { @@ -24,8 +25,7 @@ void do_alias(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { SendMsgToChar(" Нет алиасов.\r\n", ch); else { while (a != nullptr) { - sprintf(buf, "%-15s %s\r\n", a->alias, a->replacement); - SendMsgToChar(buf, ch); + SendMsgToChar(fmt::format("{:<15} {}\r\n", a->alias, a->replacement), ch); a = a->next; } } diff --git a/src/engine/ui/cmd/do_commands.cpp b/src/engine/ui/cmd/do_commands.cpp index 85c460ba04..3520f446bc 100644 --- a/src/engine/ui/cmd/do_commands.cpp +++ b/src/engine/ui/cmd/do_commands.cpp @@ -7,6 +7,7 @@ */ #include "engine/entities/char_data.h" +#include #include "administration/privilege.h" #include "gameplay/communication/social.h" #include "engine/db/global_objects.h" @@ -37,7 +38,7 @@ void do_commands(CharData *ch, char *argument, int/* cmd*/, int subcmd) { continue; } for (const auto &kw : soc.GetKeywords()) { - sprintf(buf + strlen(buf), "%-19s", kw.c_str()); + strcat(buf, fmt::format("{:<19}", kw).c_str()); if (!(no % 4)) strcat(buf, "\r\n"); no++; @@ -49,13 +50,13 @@ void do_commands(CharData *ch, char *argument, int/* cmd*/, int subcmd) { i = cmd_sort_info[cmd_num].sort_pos; if (wizhelp) { if (privilege::HasPrivilege(vict, std::string(cmd_info[i].command), 0, 0, 0)) { - sprintf(buf + strlen(buf), "%-15s", cmd_info[i].command); + strcat(buf, fmt::format("{:<15}", cmd_info[i].command).c_str()); if (!(no % 5)) strcat(buf, "\r\n"); no++; } } else if (cmd_info[i].minimum_level >= 0 && (static_cast(socials) == cmd_sort_info[i].is_social)) { - sprintf(buf + strlen(buf), "%-15s", cmd_info[i].command); + strcat(buf, fmt::format("{:<15}", cmd_info[i].command).c_str()); if (!(no % 5)) strcat(buf, "\r\n"); no++; diff --git a/src/engine/ui/cmd/do_create.cpp b/src/engine/ui/cmd/do_create.cpp index 717f020b51..08fd2135ac 100644 --- a/src/engine/ui/cmd/do_create.cpp +++ b/src/engine/ui/cmd/do_create.cpp @@ -24,16 +24,15 @@ void do_create(CharData *ch, char *argument, int/* cmd*/, int subcmd) { return; } - size_t i = strlen(arg); ESpellType itemnum; - if (!strn_cmp(arg, "potion", i) || !strn_cmp(arg, "напиток", i)) + if (utils::IsAbbr(arg, "potion") || utils::IsAbbr(arg, "напиток")) itemnum = ESpellType::kPotionCast; - else if (!strn_cmp(arg, "wand", i) || !strn_cmp(arg, "палочка", i)) + else if (utils::IsAbbr(arg, "wand") || utils::IsAbbr(arg, "палочка")) itemnum = ESpellType::kWandCast; - else if (!strn_cmp(arg, "scroll", i) || !strn_cmp(arg, "свиток", i)) + else if (utils::IsAbbr(arg, "scroll") || utils::IsAbbr(arg, "свиток")) itemnum = ESpellType::kScrollCast; - else if (!strn_cmp(arg, "recipe", i) || !strn_cmp(arg, "рецепт", i) || - !strn_cmp(arg, "отвар", i)) { + else if (utils::IsAbbr(arg, "recipe") || utils::IsAbbr(arg, "рецепт") || + utils::IsAbbr(arg, "отвар")) { if (subcmd != SCMD_RECIPE) { SendMsgToChar("Магическую смесь необходимо СМЕШАТЬ.\r\n", ch); return; @@ -41,7 +40,7 @@ void do_create(CharData *ch, char *argument, int/* cmd*/, int subcmd) { // itemnum = SPELL_ITEMS; compose_recipe(ch, argument, 0); return; - } else if (!strn_cmp(arg, "runes", i) || !strn_cmp(arg, "руны", i)) { + } else if (utils::IsAbbr(arg, "runes") || utils::IsAbbr(arg, "руны")) { if (subcmd != SCMD_RECIPE) { SendMsgToChar("Руны требуется сложить.\r\n", ch); return; diff --git a/src/engine/ui/cmd/do_display.cpp b/src/engine/ui/cmd/do_display.cpp index ef7ba374cd..6cc71f950e 100644 --- a/src/engine/ui/cmd/do_display.cpp +++ b/src/engine/ui/cmd/do_display.cpp @@ -7,6 +7,8 @@ */ #include "engine/entities/char_data.h" +#include "utils/russian_keys.h" +#include "utils/native_text.h" #include "administration/privilege.h" const char *DISPLAY_HELP = "Формат: статус { { Ж | Э | З | В | Д | У | О | Б | П | К } | все | нет }\r\n"; @@ -38,35 +40,35 @@ void do_display(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { const size_t len = strlen(argument); for (size_t i = 0; i < len; i++) { - switch (LOWER(argument[i])) { + switch (native_text::first_char_code_lower(argument + i)) { case 'h': - case 'ж': ch->SetFlag(EPrf::kDispHp); + case rus::kZhe: ch->SetFlag(EPrf::kDispHp); break; case 'w': - case 'з': ch->SetFlag(EPrf::kDispMana); + case rus::kZe: ch->SetFlag(EPrf::kDispMana); break; case 'm': - case 'э': ch->SetFlag(EPrf::kDispMove); + case rus::kE: ch->SetFlag(EPrf::kDispMove); break; case 'e': - case 'в': ch->SetFlag(EPrf::kDispExits); + case rus::kVe: ch->SetFlag(EPrf::kDispExits); break; case 'g': - case 'д': ch->SetFlag(EPrf::kDispMoney); + case rus::kDe: ch->SetFlag(EPrf::kDispMoney); break; case 'l': - case 'у': ch->SetFlag(EPrf::kDispLvl); + case rus::kU: ch->SetFlag(EPrf::kDispLvl); break; case 'x': - case 'о': ch->SetFlag(EPrf::kDispExp); + case rus::kO: ch->SetFlag(EPrf::kDispExp); break; - case 'б': + case rus::kBe: case 'f': ch->SetFlag(EPrf::kDispFight); break; - case 'п': + case rus::kPe: case 't': ch->SetFlag(EPrf::kDispTimed); break; - case 'к': + case rus::kKa: case 'c': ch->SetFlag(EPrf::kDispCooldowns); break; case ' ': break; diff --git a/src/engine/ui/cmd/do_exits.cpp b/src/engine/ui/cmd/do_exits.cpp index e5549e9edf..16ebec4a32 100644 --- a/src/engine/ui/cmd/do_exits.cpp +++ b/src/engine/ui/cmd/do_exits.cpp @@ -6,6 +6,7 @@ */ #include "engine/entities/char_data.h" +#include #include "administration/privilege.h" #include "gameplay/mechanics/sight.h" #include "gameplay/mechanics/illumination.h" @@ -29,10 +30,10 @@ void DoExits(CharData *ch, char * /*argument*/, int/* cmd*/, int/* subcmd*/) { for (door = 0; door < EDirection::kMaxDirNum; door++) if (EXIT(ch, door) && EXIT(ch, door)->to_room() != kNowhere && !EXIT_FLAGGED(EXIT(ch, door), EExitFlag::kClosed)) { if (privilege::IsGod(ch)) - sprintf(buf2, "%-6s - [%5d] %s\r\n", dirs_rus[door], - GET_ROOM_VNUM(EXIT(ch, door)->to_room()), world[EXIT(ch, door)->to_room()]->name); + strcpy(buf2, fmt::format("{:<6} - [{:5}] {}\r\n", dirs_rus[door], + GET_ROOM_VNUM(EXIT(ch, door)->to_room()), world[EXIT(ch, door)->to_room()]->name).c_str()); else { - sprintf(buf2, "%-6s - ", dirs_rus[door]); + strcpy(buf2, fmt::format("{:<6} - ", dirs_rus[door]).c_str()); if (is_dark(EXIT(ch, door)->to_room()) && !sight::CanSeeInDark(ch)) strcat(buf2, "слишком темно\r\n"); else { diff --git a/src/engine/ui/cmd/do_features.cpp b/src/engine/ui/cmd/do_features.cpp index 406081057e..01f03bce12 100644 --- a/src/engine/ui/cmd/do_features.cpp +++ b/src/engine/ui/cmd/do_features.cpp @@ -1,4 +1,5 @@ #include "engine/ui/color.h" +#include #include "gameplay/core/remort.h" #include "engine/entities/char_data.h" #include "gameplay/abilities/timed_abilities.h" @@ -81,17 +82,17 @@ void DisplayFeats(CharData *ch, CharData *vict, bool all_feats) { continue; } if (!ch->IsFlagged(EPrf::kBlindMode)) { - sprintf(buf, " %s%s %-30s%s\r\n", + strcpy(buf, fmt::format(" {}{} {:<30}{}\r\n", ch->HaveFeat(feat.GetId()) ? kColorGrn : CanGetFeat(ch, feat.GetId()) ? kColorNrm : kColorRed, ch->HaveFeat(feat.GetId()) ? "[И]" : CanGetFeat(ch, feat.GetId()) ? "[Д]" : "[Н]", - MUD::Feat(feat.GetId()).GetCName(), kColorNrm); + MUD::Feat(feat.GetId()).GetCName(), kColorNrm).c_str()); } else { - sprintf(buf, " %s %-30s\r\n", + strcpy(buf, fmt::format(" {} {:<30}\r\n", ch->HaveFeat(feat.GetId()) ? "[И]" : CanGetFeat(ch, feat.GetId()) ? "[Д]" : "[Н]", - MUD::Feat(feat.GetId()).GetCName()); + MUD::Feat(feat.GetId()).GetCName()).c_str()); } if (feat.IsInborn() || diff --git a/src/engine/ui/cmd/do_forget.cpp b/src/engine/ui/cmd/do_forget.cpp index 5dfb90523f..cc91b9da5f 100644 --- a/src/engine/ui/cmd/do_forget.cpp +++ b/src/engine/ui/cmd/do_forget.cpp @@ -13,8 +13,8 @@ inline bool in_mem(char *arg) { return (strlen(arg) != 0) && - (!strn_cmp("часослов", arg, strlen(arg)) || - !strn_cmp("резы", arg, strlen(arg)) || !strn_cmp("book", arg, strlen(arg))); + (utils::IsAbbr(arg, "часослов") || + utils::IsAbbr(arg, "резы") || utils::IsAbbr(arg, "book")); } void do_forget(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { @@ -28,14 +28,13 @@ void do_forget(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { return; } - size_t i = strlen(arg); - if (!strn_cmp(arg, "recipe", i) || !strn_cmp(arg, "рецепт", i) || - !strn_cmp(arg, "отвар", i)) { + if (utils::IsAbbr(arg, "recipe") || utils::IsAbbr(arg, "рецепт") || + utils::IsAbbr(arg, "отвар")) { forget_recipe(ch, argument, 0); return; } - if (!strn_cmp(arg, "все", i) || !strn_cmp(arg, "all", i)) { + if (utils::IsAbbr(arg, "все") || utils::IsAbbr(arg, "all")) { char arg2[kMaxInputLength]; two_arguments(argument, arg, arg2); if (in_mem(arg2)) { diff --git a/src/engine/ui/cmd/do_gen_comm.cpp b/src/engine/ui/cmd/do_gen_comm.cpp index 83c1138185..30c2dc3f15 100644 --- a/src/engine/ui/cmd/do_gen_comm.cpp +++ b/src/engine/ui/cmd/do_gen_comm.cpp @@ -8,6 +8,7 @@ #include "do_gen_comm.h" #include "administration/privilege.h" +#include "utils/native_text.h" #include "utils/grammar/gender.h" #include "gameplay/mechanics/sight.h" @@ -156,17 +157,25 @@ void do_gen_comm(CharData *ch, char *argument, int/* cmd*/, int subcmd) { int bad_simb_cnt = 0, bad_seq_cnt = 0; // фильтруем верхний регистр - for (int k = 0; argument[k] != '\0'; k++) { - if (a_isupper(argument[k])) { + // Counted and folded per character (issue #3681): a byte-wise scan cannot see an + // uppercase Cyrillic letter at all, so the filter silently stopped working for Russian. + // The denominator is the character count for the same reason -- with byte lengths the + // percentage would be halved for Russian text. + const size_t total_chars = native_text::char_count(argument); + for (auto letter : native_text::chars(argument)) { + if (native_text::is_upper_char(letter.data())) { bad_simb_cnt++; bad_seq_cnt++; } else bad_seq_cnt = 0; if ((bad_seq_cnt > 1) && - (((bad_simb_cnt * 100 / strlen(argument)) > bad_smb_procent) || - (bad_seq_cnt > MAX_UPPERS_SEQ_CHAR))) - argument[k] = a_lcc(argument[k]); + (((bad_simb_cnt * 100 / total_chars) > bad_smb_procent) || + (bad_seq_cnt > MAX_UPPERS_SEQ_CHAR))) { + // letter указывает внутрь argument; свёртка регистра не меняет длину. + char *at = argument + (letter.data() - argument); + native_text::copy_lower_char(at, at); + } } // фильтруем одинаковые сообщения в эфире if (!str_cmp(ch->get_last_tell().c_str(), argument)) { @@ -293,7 +302,7 @@ std::string format_gossip_name(CharData *ch, CharData *vict) { return ""; } std::string name = privilege::IsImmortal(ch) ? GET_NAME(ch) : sight::PersonName(ch, vict, 0); - name[0] = UPPER(name[0]); + native_text::capitalize_first(name); return name; } diff --git a/src/engine/ui/cmd/do_give.cpp b/src/engine/ui/cmd/do_give.cpp index 46da3faeea..4237c9f719 100644 --- a/src/engine/ui/cmd/do_give.cpp +++ b/src/engine/ui/cmd/do_give.cpp @@ -173,7 +173,7 @@ void do_give(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { else if (is_number(arg)) { auto amount = std::stoi(arg); argument = one_argument(argument, arg); - if (!strn_cmp("coin", arg, 4) || !strn_cmp("кун", arg, 3) || !str_cmp("денег", arg)) { + if (utils::IsAbbr("coin", arg) || utils::IsAbbr("кун", arg) || !str_cmp("денег", arg)) { one_argument(argument, arg); if ((vict = give_find_vict(ch, arg)) != nullptr) perform_give_gold(ch, vict, amount); diff --git a/src/engine/ui/cmd/do_ignore.cpp b/src/engine/ui/cmd/do_ignore.cpp index 8aa55b0470..2ba9d612f3 100644 --- a/src/engine/ui/cmd/do_ignore.cpp +++ b/src/engine/ui/cmd/do_ignore.cpp @@ -7,6 +7,7 @@ */ #include "engine/entities/char_data.h" +#include "utils/native_text.h" #include "utils/utils_string.h" #include "utils/utils.h" #include "engine/core/comm.h" @@ -43,7 +44,7 @@ void do_ignore(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { strcpy(name, "Все"); } else { strcpy(name, ign_find_name(ignore->id)); - name[0] = UPPER(name[0]); + native_text::capitalize_first(name); } sprintf(buf, " %s: ", name); SendMsgToChar(buf, ch); @@ -146,7 +147,7 @@ void do_ignore(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { SendMsgToChar("Вы и так не игнорируете всех сразу.\r\n", ch); } else { strcpy(name, ign_find_name(vict_id)); - name[0] = UPPER(name[0]); + native_text::capitalize_first(name); sprintf(buf, "Вы и так не игнорируете " "персонажа %s%s%s.\r\n", kColorWht, name, kColorNrm); @@ -173,7 +174,7 @@ void do_ignore(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { SendMsgToChar(buf, ch); } else { strcpy(name, ign_find_name(ignore->id)); - name[0] = UPPER(name[0]); + native_text::capitalize_first(name); sprintf(buf, "Для персонажа %s%s%s вы игнорируете:%s.\r\n", kColorWht, name, kColorNrm, text_ignore_modes(ignore->mode, buf1)); SendMsgToChar(buf, ch); @@ -183,7 +184,7 @@ void do_ignore(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { SendMsgToChar("Вы больше не игнорируете всех сразу.\r\n", ch); } else { strcpy(name, ign_find_name(vict_id)); - name[0] = UPPER(name[0]); + native_text::capitalize_first(name); sprintf(buf, "Вы больше не игнорируете персонажа %s%s%s.\r\n", kColorWht, name, kColorNrm); SendMsgToChar(buf, ch); diff --git a/src/engine/ui/cmd/do_put.cpp b/src/engine/ui/cmd/do_put.cpp index eba3c0fc11..263929656f 100644 --- a/src/engine/ui/cmd/do_put.cpp +++ b/src/engine/ui/cmd/do_put.cpp @@ -158,7 +158,7 @@ void do_put(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { else if (isname(theplace, "экипировка equipment")) where_bits = EFind::kObjEquip; - if (theobj && (!strn_cmp("coin", theobj, 4) || !strn_cmp("кун", theobj, 3))) { + if (theobj && (utils::IsAbbr("coin", theobj) || utils::IsAbbr("кун", theobj))) { money_mode = true; if (howmany <= 0) { SendMsgToChar("Следует указать чиста конкретную сумму.\r\n", ch); diff --git a/src/engine/ui/cmd/do_score.cpp b/src/engine/ui/cmd/do_score.cpp index 53e91692ce..17426e2620 100644 --- a/src/engine/ui/cmd/do_score.cpp +++ b/src/engine/ui/cmd/do_score.cpp @@ -6,6 +6,7 @@ слабовидящих. */ +#include "utils/native_text.h" #include "engine/ui/color.h" #include "gameplay/affects/affect_messages.h" #include "utils/utils_string.h" @@ -118,9 +119,9 @@ void PrintBonusStateInfo(CharData *ch, std::ostringstream &out); // \todo Переписать на вывод в поток с использованием общих со "счет все" функций void PrintScoreList(CharData *ch) { sprintf(buf, "%s", MUD::RaceMessages().GetMessage(GET_RACE(ch), ch->get_sex()).c_str()); - buf[0] = LOWER(buf[0]); + native_text::copy_lower_char(buf, buf); sprintf(buf1, "%s", religion_name[GET_RELIGION(ch)][static_cast(ch->get_sex())]); - buf1[0] = LOWER(buf1[0]); + native_text::copy_lower_char(buf1, buf1); SendMsgToChar(ch, "Вы %s, %s, %s, %s, уровень %d, перевоплощений %d.\r\n", ch->get_name().c_str(), buf, MUD::Class(ch->GetClass()).GetCName(), @@ -232,12 +233,12 @@ void PrintScoreList(CharData *ch) { } else if (NAME_BAD(ch)) { SendMsgToChar(ch, "ВНИМАНИЕ! ваше имя запрещено богами. Очень скоро вы прекратите получать опыт.\r\n"); } - SendMsgToChar(ch, "Вы можете вступить в группу с максимальной разницей в %2d %-75s\r\n", + SendMsgToChar(fmt::format("Вы можете вступить в группу с максимальной разницей в {:2} {:<75.76}\r\n", grouping[ch->GetClass()][static_cast(remort::GetRealRemort(ch))], - (std::string( + grammar::GetDeclensionInNumber(grouping[ch->GetClass()][static_cast(remort::GetRealRemort( ch))], grammar::EWhat::kLvl) - + std::string(" без потерь для опыта.")).substr(0, 76).c_str())); + + std::string(" без потерь для опыта.")), ch); SendMsgToChar(ch, "Вы можете принять в группу максимум %d соратников.\r\n", group::max_group_size(ch)); std::ostringstream out; diff --git a/src/engine/ui/cmd/do_sign.cpp b/src/engine/ui/cmd/do_sign.cpp index 276fd7db99..00e58cf4f9 100644 --- a/src/engine/ui/cmd/do_sign.cpp +++ b/src/engine/ui/cmd/do_sign.cpp @@ -5,6 +5,7 @@ #include "gameplay/mechanics/liquid.h" #include "gameplay/fight/pk.h" #include "engine/core/utils_char_obj.inl" +#include "utils/native_text.h" // чтоб не абузили длину. персональные пофиг, а клановые не надо. const int kMaxLabelLength = 32; @@ -72,8 +73,10 @@ void DoSign(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { GET_NAME(ch), target->get_short_description().c_str(), GET_OBJ_VNUM(target)); act("Вы затерли надписи на $o5.", false, ch, target, nullptr, kToChar); } else if (labels) { - if (strlen(labels) > kMaxLabelLength) - labels[kMaxLabelLength] = '\0'; + // Предел -- в символах: по байтам русская метка обрезалась вдвое короче + // и могла разрубить символ пополам (issue #3681). + if (native_text::char_count(labels) > static_cast(kMaxLabelLength)) + labels[native_text::char_offset(labels, kMaxLabelLength)] = '\0'; // убираем тильды for (int i = 0; labels[i] != '\0'; i++) diff --git a/src/engine/ui/cmd/do_skills.cpp b/src/engine/ui/cmd/do_skills.cpp index a3ff7c1d74..fbdccf49e3 100644 --- a/src/engine/ui/cmd/do_skills.cpp +++ b/src/engine/ui/cmd/do_skills.cpp @@ -1,4 +1,5 @@ #include "do_skills.h" +#include #include "engine/ui/color.h" #include "engine/entities/char_data.h" @@ -77,12 +78,13 @@ void DisplaySkills(CharData *ch, CharData *vict, const char *filter/* = nullptr* default: sprintf(buf, " "); } - sprintf(buf + strlen(buf), "%-23s %s (%d)%s \r\n", + // Ширина колонки - в символах, а не в байтах (issue #3681). + strcat(buf, fmt::format("{:<23} {} ({}){} \r\n", skill.GetName(), how_good(GetSkill(ch, skill_id), CalcSkillHardCap(ch, skill_id)), GetTrainedSkill(ch, skill_id) == 0 ? GetEquippedSkill(ch, skill_id) : std::min(CalcSkillMinCap(ch, skill_id) + GetEquippedSkill(ch, skill_id), MUD::Skill(skill_id).cap), - kColorNrm); + kColorNrm).c_str()); skills_names.emplace_back(buf); i++; } diff --git a/src/engine/ui/cmd/do_spells.cpp b/src/engine/ui/cmd/do_spells.cpp index 690bfbb6a2..55175fd269 100644 --- a/src/engine/ui/cmd/do_spells.cpp +++ b/src/engine/ui/cmd/do_spells.cpp @@ -1,4 +1,5 @@ #include "do_spells.h" +#include #include "administration/privilege.h" #include "gameplay/mechanics/magic_item.h" @@ -10,6 +11,7 @@ #include "engine/db/global_objects.h" #include "gameplay/mechanics/weather.h" #include "gameplay/core/remort.h" +#include "utils/native_text.h" #include @@ -47,11 +49,17 @@ void DisplaySpells(CharData *ch, CharData *vict, bool all) { char names[kMaxMemoryCircle][kMaxStringLength]; std::string time_str; int slots[kMaxMemoryCircle], i, max_slot = 0, slot_num, gcount = 0; + // Разбивка на колонки считается в символах, а не в байтах: под UTF-8 байт на символ уже не + // один, и прежняя арифметика по длине строки разносила колонки (issue #3681). slots[] + // остаётся смещением в буфере -- оно по природе байтовое, -- а chars[] хранит, сколько + // символов в этот круг уже набрано. + int chars[kMaxMemoryCircle]; auto have_spells{false}; max_slot = 0; for (i = 0; i < kMaxMemoryCircle; i++) { *names[i] = '\0'; slots[i] = 0; + chars[i] = 0; } for (const auto &spl_info : MUD::Spells()) { @@ -87,16 +95,21 @@ void DisplaySpells(CharData *ch, CharData *vict, bool all) { if (CalcSpellManacost(ch, spell_id) > Mana(GetRealWis(ch))) continue; if (CheckRecipeItems(ch, spell_id, ESpellType::kRunes, false)) { - slots[slot_num] += sprintf(names[slot_num] + slots[slot_num], - "%s|<...%4d.> %s%-38s&n|", - slots[slot_num] % 114 < - 10 ? "\r\n" : " ", - CalcSpellManacost(ch, spell_id), GetSpellColor(spell_id), MUD::Spell(spell_id).GetCName()); + const auto line = fmt::format("{}|<...{:4}.> {}{:<38}&n|", + chars[slot_num] % 114 < 10 ? "\r\n" : " ", + CalcSpellManacost(ch, spell_id), GetSpellColor(spell_id), + MUD::Spell(spell_id).GetCName()); + strcpy(names[slot_num] + slots[slot_num], line.c_str()); + slots[slot_num] += static_cast(line.size()); + chars[slot_num] += static_cast(native_text::char_count(line)); } else { if (all) { - slots[slot_num] += sprintf(names[slot_num] + slots[slot_num], - "%s|+--------+ %s%-38s&n|", slots[slot_num] % 114 < 10 ? "\r\n" - : " ", GetSpellColor(spell_id), MUD::Spell(spell_id).GetCName()); + const auto line = fmt::format("{}|+--------+ {}{:<38}&n|", + chars[slot_num] % 114 < 10 ? "\r\n" : " ", GetSpellColor(spell_id), + MUD::Spell(spell_id).GetCName()); + strcpy(names[slot_num] + slots[slot_num], line.c_str()); + slots[slot_num] += static_cast(line.size()); + chars[slot_num] += static_cast(native_text::char_count(line)); } } } else { @@ -114,9 +127,10 @@ void DisplaySpells(CharData *ch, CharData *vict, bool all) { else { sprintf(buf1, "%s", "K"); } - slots[slot_num] += sprintf(names[slot_num] + slots[slot_num], - "%s|<%s%c%c%c%c%c%c%c>%s%s%-30s %-7s&n|", - slots[slot_num] % 116 < 10 ? "\r\n" : " ", + // fmt, а не sprintf: ширину поля с названием заклинания надо мерить в символах. + // printf считает %-30s в байтах, и под UTF-8 колонка разъезжалась (issue #3681). + const auto line = fmt::format("{}|<{}{}{}{}{}{}{}{}>{}{}{:<30} {:<7}&n|", + chars[slot_num] % 116 < 10 ? "\r\n" : " ", IS_SET(GET_SPELL_TYPE(ch, spell_id), ESpellType::kKnow) ? buf1 : ".", IS_SET(GET_SPELL_TYPE(ch, spell_id), ESpellType::kTemp) ? 'T' : '.', IS_SET(GET_SPELL_TYPE(ch, spell_id), ESpellType::kPotionCast) ? 'P' : '.', @@ -124,11 +138,14 @@ void DisplaySpells(CharData *ch, CharData *vict, bool all) { IS_SET(GET_SPELL_TYPE(ch, spell_id), ESpellType::kScrollCast) ? 'S' : '.', IS_SET(GET_SPELL_TYPE(ch, spell_id), ESpellType::kItemCast) ? 'I' : '.', IS_SET(GET_SPELL_TYPE(ch, spell_id), ESpellType::kRunes) ? 'R' : '.', - '.', + '.', (CalcMinSpellLvl(ch, spell_id) - GetRealLevel(ch) < 10) ? " " : " ", GetSpellColor(spell_id), MUD::Spell(spell_id).GetCName(), - time_str.c_str()); + time_str); + strcpy(names[slot_num] + slots[slot_num], line.c_str()); + slots[slot_num] += static_cast(line.size()); + chars[slot_num] += static_cast(native_text::char_count(line)); } have_spells = true; }; diff --git a/src/engine/ui/cmd/do_statistic.cpp b/src/engine/ui/cmd/do_statistic.cpp index fc87acdeda..4f7e817f20 100644 --- a/src/engine/ui/cmd/do_statistic.cpp +++ b/src/engine/ui/cmd/do_statistic.cpp @@ -6,6 +6,7 @@ \detail Detail description. */ +#include #include "engine/entities/char_data.h" #include "engine/db/global_objects.h" #include "engine/ui/color.h" @@ -63,7 +64,7 @@ void do_statistic(CharData *ch, char * /*argument*/, int/* cmd*/, int/* subcmd*/ const int class_name_col_width{15}; const int number_col_width{3}; for (const auto &it : players) { - out << std::left << std::setw(class_name_col_width) << MUD::Class(it.first).GetPluralName() << " " + out << fmt::format("{:<{}}", MUD::Class(it.first).GetPluralName(), class_name_col_width) << " " << kColorBoldRed << "[" << kColorBoldCyn << std::setw(number_col_width) << std::right << it.second.first + it.second.second << kColorBoldRed << "|" << kColorBoldCyn @@ -82,22 +83,22 @@ void do_statistic(CharData *ch, char * /*argument*/, int/* cmd*/, int/* subcmd*/ const int headline_width{33}; - out << std::left << std::setw(headline_width) << " Всего игроков:"; + out << fmt::format("{:<{}}", " Всего игроков:", headline_width); PrintValue(out, number_col_width, total); - out << std::left << std::setw(headline_width) << " Игроков выше|ниже 25 уровня:"; + out << fmt::format("{:<{}}", " Игроков выше|ниже 25 уровня:", headline_width); PrintPair(out, number_col_width, hilvl, lowlvl); - out << std::left << std::setw(headline_width) << " Игроков с перевоплощениями|без:"; + out << fmt::format("{:<{}}", " Игроков с перевоплощениями|без:", headline_width); PrintPair(out, number_col_width, rem, norem); - out << std::left << std::setw(headline_width) << " Клановых|внеклановых игроков:"; + out << fmt::format("{:<{}}", " Клановых|внеклановых игроков:", headline_width); PrintPair(out, number_col_width, clan, noclan); - out << std::left << std::setw(headline_width) << " Игроков с флагами ПК|без ПК:"; + out << fmt::format("{:<{}}", " Игроков с флагами ПК|без ПК:", headline_width); PrintPair(out, number_col_width, pk, nopk); - out << std::left << std::setw(headline_width) << " Героев (без ПК) | Тварей убито:"; + out << fmt::format("{:<{}}", " Героев (без ПК) | Тварей убито:", headline_width); const int kills_col_width{5}; PrintPair(out, kills_col_width, char_stat::players_killed, char_stat::mobs_killed); out << "\r\n"; diff --git a/src/engine/ui/cmd/do_telegram.cpp b/src/engine/ui/cmd/do_telegram.cpp index 26a0b3832c..e3f311b6f9 100644 --- a/src/engine/ui/cmd/do_telegram.cpp +++ b/src/engine/ui/cmd/do_telegram.cpp @@ -44,8 +44,10 @@ void do_telegram([[maybe_unused]] CharData *ch, [[maybe_unused]] char *argument, } snprintf(smallBuf, kMaxInputLength, "Поступила телега от %s, сообщают следующее:\r\n%s", GET_NAME(ch), output); - codepages::koi_to_utf8(const_cast(smallBuf), utfBuf); - if (strlen(utfBuf) < 10) { + // Движок держит текст в UTF-8, потребитель тоже ждёт UTF-8 -- границы здесь больше нет. + // Перекодировка, оставшаяся с байтовых времён, теперь разбирала бы готовый UTF-8 как + // KOI8-R и удваивала каждую букву (issue #3681). + if (strlen(smallBuf) < 10) { SendMsgToChar("Ошибочка вышла..\r\n", ch); return; } diff --git a/src/engine/ui/cmd/do_toggle.cpp b/src/engine/ui/cmd/do_toggle.cpp index b3dcc7000d..2bfaed51db 100644 --- a/src/engine/ui/cmd/do_toggle.cpp +++ b/src/engine/ui/cmd/do_toggle.cpp @@ -2,6 +2,7 @@ // Created by Sventovit on 07.09.2024. // +#include "utils/native_text.h" #include "engine/entities/char_data.h" #include "gameplay/clans/house.h" @@ -41,7 +42,7 @@ void do_toggle(CharData *ch, char * /*argument*/, int/* cmd*/, int/* subcmd*/) { " Сжатый режим : %-3s \r\n" " Повтор команд : %-3s " " Обращения : %-3s " - " Кто-то : %-6s \r\n" + " Кто-то : %s \r\n" " Болтать : %-3s " " Орать : %-3s \r\n" " Аукцион : %-3s " @@ -49,7 +50,7 @@ void do_toggle(CharData *ch, char * /*argument*/, int/* cmd*/, int/* subcmd*/) { " Автозаучивание: %-3s \r\n" " Призыв : %-3s " " Автозавершение: %-3s " - " Группа (вид) : %-7s \r\n" + " Группа (вид) : %s \r\n" " Без двойников : %-3s " " Автопомощь : %-3s " " Автодележ : %-3s \r\n" @@ -59,13 +60,13 @@ void do_toggle(CharData *ch, char * /*argument*/, int/* cmd*/, int/* subcmd*/) { " Трусость : %-3s " " Ширина экрана : %-3d " " Высота экрана : %-3d \r\n" - " Сжатие : %-6s " + " Сжатие : %s " " Новости (вид) : %-5s " " Доски : %-3s \r\n" " Хранилище : %-8s" " Пклист : %-3s " " Политика : %-3s \r\n" - " Пкформат : %-6s " + " Пкформат : %s " " Соклановцы : %-8s" " Оффтоп : %-3s \r\n" " Потеря связи : %-3s " @@ -76,7 +77,7 @@ void do_toggle(CharData *ch, char * /*argument*/, int/* cmd*/, int/* subcmd*/) { BoolToOnOffStr(ch->IsFlagged(EPrf::kCompact)), (ch->IsFlagged(EPrf::kNoRepeat) ? "NO" : "YES"), BoolToOnOffStr(!ch->IsFlagged(EPrf::kNoTell)), - ch->IsFlagged(EPrf::kNoInvistell) ? "нельзя" : "можно", + native_text::pad_right(ch->IsFlagged(EPrf::kNoInvistell) ? "нельзя" : "можно", 6).c_str(), BoolToOnOffStr(!ch->IsFlagged(EPrf::kNoGossip)), BoolToOnOffStr(!ch->IsFlagged(EPrf::kNoHoller)), BoolToOnOffStr(!ch->IsFlagged(EPrf::kNoAuction)), @@ -84,7 +85,7 @@ void do_toggle(CharData *ch, char * /*argument*/, int/* cmd*/, int/* subcmd*/) { BoolToOnOffStr(ch->IsFlagged(EPrf::kAutomem)), BoolToOnOffStr(ch->IsFlagged(EPrf::KSummonable)), BoolToOnOffStr(ch->IsFlagged(EPrf::kGoAhead)), - ch->IsFlagged(EPrf::kShowGroup) ? "полный" : "краткий", + native_text::pad_right(ch->IsFlagged(EPrf::kShowGroup) ? "полный" : "краткий", 7).c_str(), BoolToOnOffStr(ch->IsFlagged(EPrf::kNoClones)), BoolToOnOffStr(ch->IsFlagged(EPrf::kAutoassist)), BoolToOnOffStr(ch->IsFlagged(EPrf::kAutosplit)), @@ -95,7 +96,7 @@ void do_toggle(CharData *ch, char * /*argument*/, int/* cmd*/, int/* subcmd*/) { (ch)->player_specials->saved.stringLength, (ch)->player_specials->saved.stringWidth, #if defined(HAVE_ZLIB) - ch->desc->deflate == nullptr ? "нет" : (ch->desc->mccp_version == 2 ? "MCCPv2" : "MCCPv1"), + native_text::pad_right(ch->desc->deflate == nullptr ? "нет" : (ch->desc->mccp_version == 2 ? "MCCPv2" : "MCCPv1"), 6).c_str(), #else "N/A", #endif @@ -104,7 +105,7 @@ void do_toggle(CharData *ch, char * /*argument*/, int/* cmd*/, int/* subcmd*/) { GetChestMode(ch).c_str(), BoolToOnOffStr(ch->IsFlagged(EPrf::kPklMode)), BoolToOnOffStr(ch->IsFlagged(EPrf::kPolitMode)), - ch->IsFlagged(EPrf::kPkFormatMode) ? "краткий" : "полный", + native_text::pad_right(ch->IsFlagged(EPrf::kPkFormatMode) ? "краткий" : "полный", 6).c_str(), BoolToOnOffStr(ch->IsFlagged(EPrf::kClanmembersMode)), BoolToOnOffStr(ch->IsFlagged(EPrf::kOfftopMode)), BoolToOnOffStr(ch->IsFlagged(EPrf::kAntiDcMode)), @@ -114,19 +115,19 @@ void do_toggle(CharData *ch, char * /*argument*/, int/* cmd*/, int/* subcmd*/) { if ((ch)->player_specials->saved.ntfyExchangePrice > 0) { sprintf(buf, " Уведомления : %-7ld ", (ch)->player_specials->saved.ntfyExchangePrice); } else { - sprintf(buf, " Уведомления : %-7s ", "Нет"); + sprintf(buf, " Уведомления : %s ", native_text::pad_right("Нет", 7).c_str()); } SendMsgToChar(buf, ch); snprintf(buf, kMaxStringLength, " Карта : %-3s " " Вход в зону : %-3s \r\n" - " Магщиты (вид) : %-8s" + " Магщиты (вид) : %s" " Автопризыв : %-5s " " Маппер : %-3s \r\n" " Контроль IP : %-6s ", BoolToOnOffStr(ch->IsFlagged(EPrf::kDrawMap)), BoolToOnOffStr(ch->IsFlagged(EPrf::kShowZoneNameOnEnter)), - (ch->IsFlagged(EPrf::kBriefShields) ? "краткий" : "полный"), + native_text::pad_right(ch->IsFlagged(EPrf::kBriefShields) ? "краткий" : "полный", 8).c_str(), BoolToOnOffStr(ch->IsFlagged(EPrf::kAutonosummon)), BoolToOnOffStr(ch->IsFlagged(EPrf::kMapper)), BoolToOnOffStr(ch->IsFlagged(EPrf::kIpControl))); diff --git a/src/engine/ui/cmd/do_where.cpp b/src/engine/ui/cmd/do_where.cpp index 73ad5cb5e5..09f9714dc4 100644 --- a/src/engine/ui/cmd/do_where.cpp +++ b/src/engine/ui/cmd/do_where.cpp @@ -3,6 +3,8 @@ // #include "engine/entities/char_data.h" +#include "engine/ui/modify.h" +#include "utils/native_text.h" #include "administration/privilege.h" #include "engine/db/world_objects.h" #include "gameplay/economics/exchange.h" @@ -63,7 +65,7 @@ void PerformImmortWhere(CharData *ch, char *arg) { } } } - SendMsgToChar(ss.str(), ch); + page_string(ch->desc, ss.str()); // список игроков тоже бывает длинным } else { std::vector rows; target_resolver::Query q; @@ -86,7 +88,12 @@ void PerformImmortWhere(CharData *ch, char *arg) { found = 1; } if (found) { - SendMsgToChar(where_format::FormatWhere(rows), ch); + // Постранично, а не одним куском: буфер вывода дескриптора ограничен байтами + // (kLargeBufSize, около 48 КБ), а под UTF-8 русский текст занимает вдвое больше, + // чем занимал в KOI8-R. Поиск по частому слову перестал влезать, и игрок получал + // "***ПЕРЕПОЛНЕНИЕ***" вместо списка. Так же выводят свои длинные списки склад, + // обменник и "кто" (issue #3681). + page_string(ch->desc, where_format::FormatWhere(rows)); } else { SendMsgToChar("Нет ничего похожего.\r\n", ch); } @@ -145,8 +152,8 @@ void PerformMortalWhere(CharData *ch, char *arg) { continue; } - sprintf(buf, "%-20s - %s\r\n", GET_NAME(i), world[i->in_room]->name); - SendMsgToChar(buf, ch); + // Ширина колонки - в символах, а не в байтах (issue #3681). + SendMsgToChar(fmt::format("{:<20} - {}\r\n", GET_NAME(i), world[i->in_room]->name), ch); } } else // print only FIRST char, not all. { @@ -165,8 +172,7 @@ void PerformMortalWhere(CharData *ch, char *arg) { continue; } - sprintf(buf, "%-25s - %s\r\n", GET_NAME(i), world[i->in_room]->name); - SendMsgToChar(buf, ch); + SendMsgToChar(fmt::format("{:<25} - {}\r\n", GET_NAME(i), world[i->in_room]->name), ch); return; } SendMsgToChar("Никого похожего с этим именем нет.\r\n", ch); @@ -196,9 +202,11 @@ std::string where_format::FormatWhere(const std::vector &rows) { for (const auto &row : rows) { const std::string prefix = fmt::format("{:>{}}. {:<5} [{:>7}] {:<25} - ", row.num, num_width, RowKindLabel(row.kind), row.vnum, row.name); - // Отступ строк-продолжений = длине префикса, чтобы разделитель " - " - // встал ровно под разделителем первой строки. - const std::string cont = fmt::format("{:>{}}", " - ", static_cast(prefix.size())); + // Отступ строк-продолжений = ШИРИНЕ префикса в символах, чтобы разделитель " - " + // встал ровно под разделителем первой строки. Именно в символах, а не в байтах: + // prefix.size() под UTF-8 больше числа колонок, и отступ уезжал (issue #3681). + const std::string cont = + fmt::format("{:>{}}", " - ", static_cast(native_text::char_count(prefix))); out += prefix; if (!row.location_lines.empty()) { diff --git a/src/engine/ui/cmd/do_who.cpp b/src/engine/ui/cmd/do_who.cpp index 6dfbbb12fb..198d7b2955 100644 --- a/src/engine/ui/cmd/do_who.cpp +++ b/src/engine/ui/cmd/do_who.cpp @@ -3,6 +3,9 @@ // #include "engine/ui/cmd/do_who.h" +#include "utils/russian_keys.h" +#include "utils/native_text.h" +#include #include "administration/privilege.h" #include "utils/grammar/gender.h" #include "gameplay/mechanics/sight.h" @@ -51,10 +54,10 @@ void DoWho(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { sscanf(arg, "%d-%d", &low, &high); strcpy(buf, buf1); } else if (*arg == '-') { - const char mode = *(arg + 1); // just in case; we destroy arg in the switch + const char32_t mode = native_text::first_char_code(arg + 1); // just in case; we destroy arg in the switch switch (mode) { case 'b': - case 'и': + case rus::kI: if (privilege::IsImmortal(ch) || GET_GOD_FLAG(ch, EGf::kDemigod) || ch->IsFlagged(EPrf::kCoderinfo)) showname = true; strcpy(buf, buf1); @@ -164,15 +167,19 @@ void DoWho(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { if (short_list) { char tmp[kMaxInputLength]; snprintf(tmp, sizeof(tmp), "%s%s%s", GetPkNameColor(tch), GET_NAME(tch), kColorNrm); + // Ширина колонки - в символах, а не в байтах (issue #3681): fmt "{:<30}" считает + // байты, из-за чего колонка с русским именем под UTF-8 выходит вдвое уже. if (privilege::IsImpl(ch) || ch->IsFlagged(EPrf::kCoderinfo)) { - sprintf(buf, "%s[%2d %s] %-30s%s", + strcpy(buf, fmt::format("{}[{:2} {}] {:<30}{}", privilege::IsGod(tch.get()) ? kColorWht : "", GetRealLevel(tch), MUD::Class(tch->GetClass()).GetCName(), - tmp, privilege::IsGod(tch.get()) ? kColorNrm : ""); + tmp, + privilege::IsGod(tch.get()) ? kColorNrm : "").c_str()); } else { - sprintf(buf, "%s%-30s%s", + strcpy(buf, fmt::format("{}{:<30}{}", privilege::IsImmortal(tch.get()) ? kColorWht : "", - tmp, privilege::IsImmortal(tch.get()) ? kColorNrm : ""); + tmp, + privilege::IsImmortal(tch.get()) ? kColorNrm : "").c_str()); } } else { if (privilege::IsImpl(ch) diff --git a/src/engine/ui/cmd/do_who_am_i.cpp b/src/engine/ui/cmd/do_who_am_i.cpp index c9189e44b7..31998bcc34 100644 --- a/src/engine/ui/cmd/do_who_am_i.cpp +++ b/src/engine/ui/cmd/do_who_am_i.cpp @@ -7,6 +7,7 @@ */ #include "engine/entities/char_data.h" +#include "utils/native_text.h" #include "gameplay/clans/house.h" #include "engine/db/player_index.h" #include "gameplay/core/remort.h" @@ -29,7 +30,7 @@ void DoWhoAmI(CharData *ch, char * /*argument*/, int/* cmd*/, int/* subcmd*/) { } else { const int god_level = (ch)->player_specials->saved.NameGod > 1000 ? (ch)->player_specials->saved.NameGod - 1000 : (ch)->player_specials->saved.NameGod; sprintf(buf1, "%s", GetNameById((ch)->player_specials->saved.NameIDGod).c_str()); - *buf1 = UPPER(*buf1); + native_text::capitalize_first(buf1); static const char *by_rank_god = "Богом"; static const char *by_rank_privileged = "привилегированным игроком"; diff --git a/src/engine/ui/cmd_god/do_inspect.cpp b/src/engine/ui/cmd_god/do_inspect.cpp index 75fb25af95..d8ef3a3a8c 100644 --- a/src/engine/ui/cmd_god/do_inspect.cpp +++ b/src/engine/ui/cmd_god/do_inspect.cpp @@ -13,6 +13,7 @@ #include "fmt/chrono.h" #include "utils/utils_time.h" #include "engine/db/player_index.h" +#include "utils/native_text.h" const int kMaxRequestLength{65}; const int kMinRequestLength{3}; @@ -609,11 +610,12 @@ bool InspectRequestDeque::IsBusy(const CharData *ch) { bool InspectRequestDeque::IsArgsValid(const CharData *ch, const std::vector &args) { auto &request_text = args[kRequestTextPos]; - if (request_text.length() < kMinRequestLength) { + // Длина запроса -- в символах: по байтам русский текст считался вдвое длиннее (issue #3681). + if (native_text::char_count(request_text) < kMinRequestLength) { SendMsgToChar("Слишком короткий запрос.\r\n", ch); return false; } - if (request_text.length() > kMaxRequestLength) { + if (native_text::char_count(request_text) > kMaxRequestLength) { SendMsgToChar("Слишком длинный запрос.\r\n", ch); return false; } diff --git a/src/engine/ui/cmd_god/do_last.cpp b/src/engine/ui/cmd_god/do_last.cpp index 7ede9aab74..a19220429c 100644 --- a/src/engine/ui/cmd_god/do_last.cpp +++ b/src/engine/ui/cmd_god/do_last.cpp @@ -7,6 +7,7 @@ */ #include "engine/entities/char_data.h" +#include #include "administration/privilege.h" #include "engine/entities/char_player.h" #include "engine/db/global_objects.h" @@ -28,11 +29,13 @@ void DoPageLastLogins(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) SendMsgToChar("Вы не столь уж и божественны для этого.\r\n", ch); } else { time_t tmp_time = chdata->get_last_logon(); - sprintf(buf, "[%5ld] [%2d %s] %-12s : %-18s : %-20s\r\n", + SendMsgToChar(fmt::format("[{:5}] [{:2} {}] {:<12} : {:<18} : {:<20}\r\n", chdata->get_uid(), GetRealLevel(chdata), - MUD::Class(chdata->GetClass()).GetAbbr().c_str(), GET_NAME(chdata), - chdata->player_specials->saved.LastIP[0] ? chdata->player_specials->saved.LastIP : "НеВедется", ctime(&tmp_time)); - SendMsgToChar(buf, ch); + MUD::Class(chdata->GetClass()).GetAbbr(), + GET_NAME(chdata), + chdata->player_specials->saved.LastIP[0] + ? chdata->player_specials->saved.LastIP : "НеВедется", + ctime(&tmp_time)), ch); } } diff --git a/src/engine/ui/cmd_god/do_liblist.cpp b/src/engine/ui/cmd_god/do_liblist.cpp index 66abc672df..f7a5da3b3d 100644 --- a/src/engine/ui/cmd_god/do_liblist.cpp +++ b/src/engine/ui/cmd_god/do_liblist.cpp @@ -263,8 +263,8 @@ void Print(CharData *ch, int first, int last, const std::string &options) { int cnt = 0; for (int i = 0; i <= top_of_mobt; ++i) { if (mob_index[i].vnum >= first && mob_index[i].vnum <= last) { - fmt::format_to(std::back_inserter(out), "{:5}. {:<45} [{:<6}] [{:<2}]{}", - ++cnt, mob_proto[i].get_name_str().substr(0, 45), + fmt::format_to(std::back_inserter(out), "{:5}. {:<45.45} [{:<6}] [{:<2}]{}", + ++cnt, mob_proto[i].get_name_str(), mob_index[i].vnum, mob_proto[i].GetLevel(), PrintFlag(mob_proto + i, options)); if (!mob_proto[i].proto_script->empty()) { diff --git a/src/engine/ui/cmd_god/do_print_armor.cpp b/src/engine/ui/cmd_god/do_print_armor.cpp index 950ed19379..4782b5e378 100644 --- a/src/engine/ui/cmd_god/do_print_armor.cpp +++ b/src/engine/ui/cmd_god/do_print_armor.cpp @@ -6,7 +6,10 @@ \detail Detail description. */ +#include #include "engine/entities/char_data.h" +#include "utils/russian_keys.h" +#include "utils/native_text.h" #include "administration/privilege.h" #include "engine/db/obj_prototypes.h" #include "engine/db/global_objects.h" @@ -34,8 +37,8 @@ void DoPrintArmor(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { char tmpbuf[kMaxInputLength]; bool find_param = false; while (*argument) { - switch (*argument) { - case 'М': argument = one_argument(++argument, tmpbuf); + switch (native_text::first_char_code(argument)) { + case rus::kEmUpper: argument = one_argument(argument + native_text::char_bytes(argument), tmpbuf); if (utils::IsAbbr(tmpbuf, "булат")) { filter.material = EObjMaterial::kBulat; } else if (utils::IsAbbr(tmpbuf, "бронза")) { @@ -78,7 +81,7 @@ void DoPrintArmor(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { } find_param = true; break; - case 'Т': argument = one_argument(++argument, tmpbuf); + case rus::kTeUpper: argument = one_argument(argument + native_text::char_bytes(argument), tmpbuf); if (utils::IsAbbr(tmpbuf, "броня") || utils::IsAbbr(tmpbuf, "armor")) { filter.type = EObjType::kArmor; } else if (utils::IsAbbr(tmpbuf, "легкие") || utils::IsAbbr(tmpbuf, "легкая")) { @@ -93,7 +96,7 @@ void DoPrintArmor(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { } find_param = true; break; - case 'О': argument = one_argument(++argument, tmpbuf); + case rus::kOUpper: argument = one_argument(argument + native_text::char_bytes(argument), tmpbuf); if (utils::IsAbbr(tmpbuf, "тело")) { filter.wear = EWearFlag::kBody; filter.wear_message = 3; @@ -118,9 +121,9 @@ void DoPrintArmor(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { } find_param = true; break; - case 'А': { + case rus::kAUpper: { bool tmp_find = false; - argument = one_argument(++argument, tmpbuf); + argument = one_argument(argument + native_text::char_bytes(argument), tmpbuf); if (!strlen(tmpbuf)) { SendMsgToChar("Неверный аффект предмета.\r\n", ch); return; @@ -194,7 +197,8 @@ void DoPrintArmor(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { find_param = true; break; } - default: ++argument; + // Незнакомая буква тоже пропускается целиком, иначе разбор съезжает на полсимвола. + default: argument += native_text::char_bytes(argument); } } if (!find_param) { @@ -312,7 +316,7 @@ void DoPrintArmor(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { out << " " << std::setw(2) << it->first << " | " << std::setw(7) << obj->get_vnum() << " | " - << std::setw(14) << material_name[obj->get_material()] << " | " + << fmt::format("{:>14}", material_name[obj->get_material()]) << " | " << obj->get_PName(grammar::ECase::kNom) << "\r\n"; for (int i = 0; i < kMaxObjAffect; i++) { diff --git a/src/engine/ui/cmd_god/do_set.cpp b/src/engine/ui/cmd_god/do_set.cpp index 61c3640266..7c0e648197 100644 --- a/src/engine/ui/cmd_god/do_set.cpp +++ b/src/engine/ui/cmd_god/do_set.cpp @@ -30,6 +30,7 @@ #include #include +#include "utils/native_text.h" enum class ESetVict { kPc, @@ -304,9 +305,9 @@ int PerformSet(CharData *ch, CharData *vict, int mode, char *val_arg) { // Find the value of the argument bool on_off_mode{false}; if (set_fields[mode].type == ESetValue::kBinary) { - if (!strn_cmp(val_arg, "on", 2) || !strn_cmp(val_arg, "yes", 3) || !strn_cmp(val_arg, "вкл", 3)) { + if (utils::IsAbbr("on", val_arg) || utils::IsAbbr("yes", val_arg) || utils::IsAbbr("вкл", val_arg)) { on_off_mode = true; - } else if (!strn_cmp(val_arg, "off", 3) || !strn_cmp(val_arg, "no", 2) || !strn_cmp(val_arg, "выкл", 4)) { + } else if (utils::IsAbbr("off", val_arg) || utils::IsAbbr("no", val_arg) || utils::IsAbbr("выкл", val_arg)) { on_off_mode = false; } else { SendMsgToChar("Значение может быть 'on' или 'off'.\r\n", ch); @@ -626,7 +627,7 @@ int PerformSet(CharData *ch, CharData *vict, int mode, char *val_arg) { SendMsgToChar(buf, ch); } else { for (i = grammar::ECase::kFirstCase; i <= grammar::ECase::kLastCase; i++) { - if (strlen(npad[i]) < kMinNameLength || strlen(npad[i]) > kMaxNameLength) { + if (static_cast(native_text::char_count(npad[i])) < kMinNameLength || static_cast(native_text::char_count(npad[i])) > kMaxNameLength) { sprintf(buf, "Падеж номер %d некорректен.\r\n", ++i); SendMsgToChar(buf, ch); return (0); @@ -634,8 +635,8 @@ int PerformSet(CharData *ch, CharData *vict, int mode, char *val_arg) { } if (_parse_name(npad[0], npad[0]) || - strlen(npad[0]) < kMinNameLength || - strlen(npad[0]) > kMaxNameLength || + static_cast(native_text::char_count(npad[0])) < kMinNameLength || + static_cast(native_text::char_count(npad[0])) > kMaxNameLength || !IsNameAvailable(npad[0]) || reserved_word(npad[0]) || fill_word(npad[0])) { SendMsgToChar("Некорректное имя.\r\n", ch); return (0); diff --git a/src/engine/ui/cmd_god/do_set_all.cpp b/src/engine/ui/cmd_god/do_set_all.cpp index e832949bc2..b254ce4721 100644 --- a/src/engine/ui/cmd_god/do_set_all.cpp +++ b/src/engine/ui/cmd_god/do_set_all.cpp @@ -7,6 +7,7 @@ */ #include "engine/ui/cmd_god/do_set_all.h" +#include "utils/native_text.h" #include "engine/db/player_index.h" #include "administration/karma.h" @@ -167,7 +168,7 @@ void setall_inspect() { } Password::set_password(vict, std::string(it->second->pwd)); std::string str = player_table[it->second->pos].name(); - str[0] = UPPER(str[0]); + native_text::capitalize_first(str); sprintf(buf2, "У персонажа %s изменен пароль (setall).", player_table[it->second->pos].name().c_str()); it->second->out += buf2; sprintf(buf1, "\r\n"); diff --git a/src/engine/ui/cmd_god/do_show.cpp b/src/engine/ui/cmd_god/do_show.cpp index 3d076c767d..0400161daa 100644 --- a/src/engine/ui/cmd_god/do_show.cpp +++ b/src/engine/ui/cmd_god/do_show.cpp @@ -9,6 +9,7 @@ #include #endif #include "administration/accounts.h" +#include "utils/native_text.h" #include "administration/ban.h" #include "administration/privilege.h" #include "engine/ui/cmd/do_features.h" @@ -258,12 +259,12 @@ void print_mob_bosses(CharData *ch, bool lvl_sort) { const auto mob = mob_proto + mob_rnum; const auto vnum = GET_MOB_VNUM(mob); - out += fmt::format("{:<3} {:<31}s [{:<2}][{:<6}] {:<31}s\r\n", + out += fmt::format("{:<3} {:<31.31} [{:<2}][{:<6}] {:<31.31}\r\n", ++cnt, - mob->get_name_str().substr(0, 31), + mob->get_name_str(), zone_table[mob_index[mob_rnum].zone].mob_level, vnum, - zone_name_str.substr(0, 31)); + zone_name_str); } page_string(ch->desc, out); } @@ -462,9 +463,9 @@ void ListSpellCreate(CharData *ch) { if (r > 0) runes_str += '|'; runes_str += std::to_string(info.runes[r]); } - SendMsgToChar(ch, "%3d) Rune spell [%3d] &W%-30s&n runes: %s level %d\r\n", + SendMsgToChar(fmt::format("{:3}) Rune spell [{:3}] &W{:<30}&n runes: {} level {}\r\n", ++i, to_underlying(spell_id), MUD::Spell(spell_id).GetCName(), - runes_str.c_str(), info.min_caster_level); + runes_str, info.min_caster_level), ch); } } @@ -596,11 +597,11 @@ void do_show(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { sprintf(buf + strlen(buf), "Имя никем не одобрено!\r\n"); } else if ((vict)->player_specials->saved.NameGod < 1000) { sprintf(buf1, "%s", GetNameById((vict)->player_specials->saved.NameIDGod).c_str()); - *buf1 = UPPER(*buf1); + native_text::capitalize_first(buf1); snprintf(buf + strlen(buf), kMaxStringLength, "Имя запрещено богом %s\r\n", buf1); } else { sprintf(buf1, "%s", GetNameById((vict)->player_specials->saved.NameIDGod).c_str()); - *buf1 = UPPER(*buf1); + native_text::capitalize_first(buf1); snprintf(buf + strlen(buf), kMaxStringLength, "Имя одобрено богом %s\r\n", buf1); } if (remort::GetRealRemort(vict) < 4) @@ -724,18 +725,19 @@ void do_show(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { && d->character->in_room != kNowhere && ((sight::CanSee(ch, d->character) && GetRealLevel(ch) >= GetRealLevel(d->character)) || ch->IsFlagged(EPrf::kCoderinfo))) { - sprintf(buf + strlen(buf), - "%-10s - подслушивается %s (map %s).\r\n", + strcat(buf, fmt::format( + "{:<10} - подслушивается {} (map {}).\r\n", GET_NAME(d->snooping->character), GET_PAD(d->character, 4), - d->snoop_with_map ? "on" : "off"); + d->snoop_with_map ? "on" : "off").c_str()); } } SendMsgToChar(*buf ? buf : "Никто не подслушивается.\r\n", ch); break; // snoop case 9: // show linkdrop SendMsgToChar(" Список игроков в состоянии 'link drop'\r\n", ch); - sprintf(buf, "%-50s%-16s %s\r\n", " Имя", "Комната", "Бездействие (тики)"); + strcpy(buf, fmt::format("{:<50}{:<16} {}\r\n", " Имя", + "Комната", "Бездействие (тики)").c_str()); SendMsgToChar(buf, ch); i = 0; for (const auto &character : character_list) { @@ -744,9 +746,9 @@ void do_show(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { continue; } ++i; - sprintf(buf, "%-50s[%6d][%6d] %d\r\n", - character->GetNameWithTitleOrRace().c_str(), GET_ROOM_VNUM(character->in_room), - GET_ROOM_VNUM(character->get_was_in_room()), character->char_specials.timer); + strcpy(buf, fmt::format("{:<50}[{:6}][{:6}] {}\r\n", + character->GetNameWithTitleOrRace(), GET_ROOM_VNUM(character->in_room), + GET_ROOM_VNUM(character->get_was_in_room()), character->char_specials.timer).c_str()); SendMsgToChar(buf, ch); } sprintf(buf, "Всего - %d\r\n", i); diff --git a/src/engine/ui/cmd_god/do_stat.cpp b/src/engine/ui/cmd_god/do_stat.cpp index fe766e7cf0..da239904f9 100644 --- a/src/engine/ui/cmd_god/do_stat.cpp +++ b/src/engine/ui/cmd_god/do_stat.cpp @@ -1,5 +1,6 @@ #include "gameplay/mechanics/equipment.h" #include "gameplay/affects/obj_affects.h" // issue.obj-affects: Diag +#include "utils/native_text.h" #include "gameplay/affects/affect_messages.h" #include "do_stat.h" #include "utils/utils_string.h" @@ -615,13 +616,15 @@ void do_stat_character(CharData *ch, CharData *k, const int virt) { // Routine to show what spells a char is affected by if (!k->affected.empty()) { for (const auto &aff : k->affected) { - std::string sline = fmt::sprintf("Заклинания: (%3d%s|%s) %s%-21s%s ", + std::string sline = fmt::sprintf("Заклинания: (%3d%s|%s) %s%s%s ", aff->duration + 1, (aff->battleflag.get(kAfPulsedec)) || (aff->battleflag.get(kAfSameTime)) ? "плс" : "мин", (aff->battleflag.get(kAfBattledec)) || (aff->battleflag.get(kAfSameTime)) ? "рнд" : "мин", kColorCyn, // issue.affect-migration: affect name by its own identity (affect_type), spell fallback. - affects::AffectMsg(aff->affect_type, affects::EAffectMsgType::kShortDesc).c_str(), + // Ширина поля -- в символах: printf меряет %-21s в байтах (issue #3681). + native_text::pad_right( + affects::AffectMsg(aff->affect_type, affects::EAffectMsgType::kShortDesc), 21).c_str(), kColorNrm); bool has_modifier = aff->modifier != 0; if (has_modifier) { @@ -858,7 +861,7 @@ void do_stat_object(CharData *ch, ObjData *j, const int virt = 0) { } } if (!str.empty()) { - str[0] = UPPER(str[0]); + native_text::capitalize_first(str); SendMsgToChar(ch, "&C%s&n", str.c_str()); } else { auto room = get_room_where_obj(j); @@ -1278,8 +1281,8 @@ void do_stat_room(CharData *ch, const int rnum = 0) { GET_ROOM_VNUM(rm->dir_option[i]->to_room()), kColorNrm); sprintbit(rm->dir_option[i]->exit_info.get_plane(0), exit_bits, tmpBuf, sizeof(tmpBuf)); snprintf(buf, sizeof(buf), - "Выход %s%-5s%s: Ведет в : [%s], Ключ: [%5d], Название: %s (%s), Тип: %s\r\n", - kColorCyn, dirs[i], kColorNrm, smallBuf, + "Выход %s%s%s: Ведет в : [%s], Ключ: [%5d], Название: %s (%s), Тип: %s\r\n", + kColorCyn, native_text::pad_right(dirs[i], 5).c_str(), kColorNrm, smallBuf, rm->dir_option[i]->key, rm->dir_option[i]->keyword ? rm->dir_option[i]->keyword : "Нет(дверь)", rm->dir_option[i]->vkeyword ? rm->dir_option[i]->vkeyword : "Нет(дверь)", tmpBuf); diff --git a/src/engine/ui/cmd_god/do_tabulate.cpp b/src/engine/ui/cmd_god/do_tabulate.cpp index dc5d9c4b89..7dc0e5bc77 100644 --- a/src/engine/ui/cmd_god/do_tabulate.cpp +++ b/src/engine/ui/cmd_god/do_tabulate.cpp @@ -96,10 +96,10 @@ int TabulateObjsByFilter(char *argument, CharData *ch) { for (const auto &i : obj_proto) { // ch не передаём: у прототипов нет наносимых меток (custom label). if (filter.check(i.get(), nullptr)) { - snprintf(line, sizeof(line), "%3d. [%7d] %-50s %s\r\n", + strcpy(line, fmt::format("{:3}. [{:7}] {:<50} {}\r\n", ++found, i->get_vnum(), - utils::RemoveColors(i->get_short_description()).c_str(), - filter.show_obj_aff(i.get()).c_str()); + utils::RemoveColors(i->get_short_description()), + filter.show_obj_aff(i.get())).c_str()); out += line; } } @@ -117,8 +117,9 @@ int TabulateMobsByName(char *searchname, CharData *ch) { for (nr = 0; nr <= top_of_mobt; nr++) { if (isname(searchname, mob_proto[nr].GetCharAliases())) { - sprintf(buf, "%3d. [%5d] %-30s (%s)\r\n", ++found, mob_index[nr].vnum, mob_proto[nr].get_npc_name().c_str(), - npc_race_types[mob_proto[nr].player_data.Race - ENpcRace::kBasic]); + strcpy(buf, fmt::format("{:3}. [{:5}] {:<30} ({})\r\n", ++found, mob_index[nr].vnum, + mob_proto[nr].get_npc_name(), + npc_race_types[mob_proto[nr].player_data.Race - ENpcRace::kBasic]).c_str()); SendMsgToChar(buf, ch); } } diff --git a/src/engine/ui/cmd_god/do_users.cpp b/src/engine/ui/cmd_god/do_users.cpp index a89f377f20..7398ea12dd 100644 --- a/src/engine/ui/cmd_god/do_users.cpp +++ b/src/engine/ui/cmd_god/do_users.cpp @@ -3,6 +3,7 @@ // #include "engine/ui/color.h" +#include #include "administration/privilege.h" #include "gameplay/classes/pc_classes.h" #include "engine/entities/char_data.h" @@ -105,7 +106,9 @@ void do_users(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { } } // end while (parser) - const char *format = "%3d %-7s %-20s %-17s %-3s %-8s "; + // Ширина колонок - в символах, а не в байтах (issue #3681): поля ниже паддятся +// через native_text, поэтому формат содержит голые "%s". + const char *format = "{:3} {:<7} {:<20} {:<17} {:<3} {:<8} "; if (showemail) { strcpy(line, "Ном Професс Имя Состояние Idl Логин Сайт E-mail\r\n"); } else { @@ -260,26 +263,17 @@ void do_users(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { if (d->character && d->character->GetCharAliases().c_str()) { if (d->original) { - sprintf(line, - format, - d->desc_num, - classname, - d->original->GetCharAliases().c_str(), - state, - idletime, - timeptr); + strcpy(line, fmt::format(fmt::runtime(format), + d->desc_num, classname, d->original->GetCharAliases().c_str(), + state, idletime, timeptr).c_str()); } else { - sprintf(line, - format, - d->desc_num, - classname, - d->character->GetCharAliases().c_str(), - state, - idletime, - timeptr); + strcpy(line, fmt::format(fmt::runtime(format), + d->desc_num, classname, d->character->GetCharAliases().c_str(), + state, idletime, timeptr).c_str()); } } else { - sprintf(line, format, d->desc_num, " - ", "UNDEFINED", state, idletime, timeptr); + strcpy(line, fmt::format(fmt::runtime(format), d->desc_num, " - ", + "UNDEFINED", state, idletime, timeptr).c_str()); } if (d && *d->host) { diff --git a/src/engine/ui/color.cpp b/src/engine/ui/color.cpp index 624ac56717..51e9e76496 100644 --- a/src/engine/ui/color.cpp +++ b/src/engine/ui/color.cpp @@ -6,6 +6,11 @@ \details Константы и функции для работы с цветами telnet. */ +#include "utils/native_text.h" + +#include +#include + #include "engine/ui/color.h" #include "utils/utils.h" @@ -228,13 +233,26 @@ size_t count_colors(const char *str, size_t len) { //возвращает строку длины len + кол-во цветов*2 для того чтоб в табличке все было ровненько //left_align выравнивание строки влево char *colored_name(const char *str, size_t len, const bool left_align) { - static char cstr[128]; - static char fmt[7]; - size_t cc = len + count_colors(str) * 2; + static char cstr[256]; + + // Ширину колонки меряем в символах: в UTF-8 буква занимает два байта, и printf-ширина, + // которая считает байты, обрезала бы поле примерно вдвое -- на базаре из-за этого разъезжались + // все колонки (issue #3681). Цветокоды на экране места не занимают, поэтому из видимой длины + // они вычитаются, а не прибавляются к ширине поля, как было в байтовом варианте. + const size_t colors = count_colors(str) * 2; + const size_t chars = native_text::char_count(str); + const size_t visible = chars > colors ? chars - colors : 0; + // ширина не может быть больше буфера: отдельные вызовы передают отрицательную len, + // которая в size_t превращается в астрономическую величину + const size_t width = std::min(len, sizeof(cstr) - 1); - if (strlen(str) < cc) { - snprintf(fmt, sizeof(fmt), "%%%s%ds", (left_align ? "-" : ""), static_cast(cc)); - snprintf(cstr, sizeof(cstr), fmt, str); + if (visible < width) { + const std::string padding(width - visible, ' '); + if (left_align) { + snprintf(cstr, sizeof(cstr), "%s%s", str, padding.c_str()); + } else { + snprintf(cstr, sizeof(cstr), "%s%s", padding.c_str(), str); + } } else { snprintf(cstr, sizeof(cstr), "%s", str); } diff --git a/src/engine/ui/commands.cpp b/src/engine/ui/commands.cpp index 02e64349ed..80210898cb 100644 --- a/src/engine/ui/commands.cpp +++ b/src/engine/ui/commands.cpp @@ -1,3 +1,4 @@ +#include #include "commands.h" #include "utils/utils.h" #include "engine/core/comm.h" @@ -233,7 +234,7 @@ void CommandEmbranchmentImplementation::print_branches_list(std::stringstream &s ss << "\r\n"; for (const auto &branch : m_branches.trie()) { const std::string &prefix = branch.prefix(); - ss << " " << std::setw(m_branches.max_length()) << prefix + ss << " " << fmt::format("{:>{}}", prefix, m_branches.max_length()) << " - " << m_branches.handlers().at(prefix)->get_help_line() << "\r\n"; } ss << "\r\n"; diff --git a/src/engine/ui/interpreter.h b/src/engine/ui/interpreter.h index 99fd1ee675..2d1aa891f3 100644 --- a/src/engine/ui/interpreter.h +++ b/src/engine/ui/interpreter.h @@ -26,7 +26,7 @@ class CharData; // to avoid inclusion of "char.hpp" void DoMove(CharData *ch, char *, int, int subcmd); -#define CMD_IS(cmd_name) (!strn_cmp(cmd_name, cmd_info[cmd].command, strlen(cmd_name))) +#define CMD_IS(cmd_name) (utils::IsAbbr(cmd_name, cmd_info[cmd].command)) void command_interpreter(CharData *ch, char *argument); // fill_word, half_chop moved to mud_string.h diff --git a/src/engine/ui/login.cpp b/src/engine/ui/login.cpp index 8dd0eaf18c..fee3449164 100644 --- a/src/engine/ui/login.cpp +++ b/src/engine/ui/login.cpp @@ -4,6 +4,11 @@ extracted from interpreter.cpp. Entry point ProcessLoginInput (was nanny). */ #include "interpreter.h" +#include "utils/russian_keys.h" +#include "utils/native_text.h" +#include "engine/boot/boot_constants.h" + +#include #include "engine/ui/system_messages.h" #include "engine/core/config.h" #include "gameplay/mechanics/condition.h" @@ -181,18 +186,28 @@ std::map new_loc_codes; // имя чара на код, отправленный на почту для подтверждения мыла при создании std::map new_char_codes; +// Посимвольно, а не побайтно (issue #3681): у многобайтной буквы хвостовой байт не проходит +// проверку "это буква", и раньше здесь отвергалось любое русское имя. Условие "*argument > 0" +// означало "символ не ASCII" (у старших байтов знаковый char отрицателен) - теперь это прямая +// проверка кодовой точки. Регистр тоже сворачивается по символу: первая буква заглавная, +// остальные строчные; длина при этом не меняется, поэтому буфер name не переполняется. int _parse_name(char *argument, char *name) { - int i; + int i = 0; - // skip whitespaces - for (i = 0; (*name = (i ? LOWER(*argument) : UPPER(*argument))); argument++, i++, name++) { - if (*argument == 'ё' - || *argument == 'Ё' - || !a_isalpha(*argument) - || *argument > 0) { + while (*argument) { + const char32_t code = native_text::first_char_code(argument); + if (code == rus::kYo || code == rus::kYoUpper + || !native_text::is_alpha_char(argument) + || code < 0x80) { return (1); } + const size_t bytes = i ? native_text::copy_lower_char(argument, name) + : native_text::copy_upper_char(argument, name); + argument += bytes; + name += bytes; + ++i; } + *name = '\0'; if (!i) { return (1); @@ -206,12 +221,19 @@ int _parse_name(char *argument, char *name) { * чтобы их в игру вообще пускало, а новых с Ё/ё соответственно брило. */ int parse_exist_name(char *argument, char *name) { - int i; + int i = 0; - // skip whitespaces - for (i = 0; (*name = (i ? LOWER(*argument) : UPPER(*argument))); argument++, i++, name++) - if (!a_isalpha(*argument) || *argument > 0) + while (*argument) { + if (!native_text::is_alpha_char(argument) || native_text::first_char_code(argument) < 0x80) { return (1); + } + const size_t bytes = i ? native_text::copy_lower_char(argument, name) + : native_text::copy_upper_char(argument, name); + argument += bytes; + name += bytes; + ++i; + } + *name = '\0'; if (!i) return (1); @@ -1115,8 +1137,8 @@ static void HandleGetName(DescriptorData *d, char *argument) { return; } else { if (parse_exist_name(argument, tmp_name) || - strlen(tmp_name) < (kMinNameLength - 1) || // дабы можно было войти чарам с 4 буквами - strlen(tmp_name) > kMaxNameLength || + static_cast(native_text::char_count(tmp_name)) < (kMinNameLength - 1) || // дабы можно было войти чарам с 4 буквами + static_cast(native_text::char_count(tmp_name)) > kMaxNameLength || !IsValidName(tmp_name) || fill_word(tmp_name) || reserved_word(tmp_name)) { iosystem::write_to_output("Некорректное имя. Повторите, пожалуйста.\r\n" "Имя : ", d); return; @@ -1147,7 +1169,7 @@ static void HandleGetName(DescriptorData *d, char *argument) { return; } - if (strlen(tmp_name) < (kMinNameLength)) { + if (static_cast(native_text::char_count(tmp_name)) < (kMinNameLength)) { iosystem::write_to_output("Некорректное имя. Повторите, пожалуйста.\r\n" "Имя : ", d); return; } @@ -1181,7 +1203,7 @@ static void HandleGetName(DescriptorData *d, char *argument) { } else // player unknown -- make new character { // еще одна проверка - if (strlen(tmp_name) < (kMinNameLength)) { + if (static_cast(native_text::char_count(tmp_name)) < (kMinNameLength)) { iosystem::write_to_output("Некорректное имя. Повторите, пожалуйста.\r\n" "Имя : ", d); return; } @@ -1226,8 +1248,8 @@ static void HandleNewChar(DescriptorData *d, char *argument) { } if (_parse_name(argument, tmp_name) || - strlen(tmp_name) < kMinNameLength || - strlen(tmp_name) > kMaxNameLength || + static_cast(native_text::char_count(tmp_name)) < kMinNameLength || + static_cast(native_text::char_count(tmp_name)) > kMaxNameLength || !IsValidName(tmp_name) || fill_word(tmp_name) || reserved_word(tmp_name)) { iosystem::write_to_output("Некорректное имя. Повторите, пожалуйста.\r\n" "Имя : ", d); return; @@ -1477,11 +1499,13 @@ static void HandleNameCase(DescriptorData *d, char *argument, int step) { GetCase(GET_PC_NAME(d->character), d->character->get_sex(), cur.idx, argument); } if (!_parse_name(argument, tmp_name) - && strlen(tmp_name) >= kMinNameLength - && strlen(tmp_name) <= kMaxNameLength - && !strn_cmp(tmp_name, - GET_PC_NAME(d->character), - std::min(kMinNameLength, strlen(GET_PC_NAME(d->character)) - 1))) { + && static_cast(native_text::char_count(tmp_name)) >= kMinNameLength + && static_cast(native_text::char_count(tmp_name)) <= kMaxNameLength + // Падеж принимается, если первые kMinNameLength символов совпали с именем. Считать + // это в байтах нельзя: под UTF-8 пятёрка -- две с половиной русские буквы (#3681). + && utils::IsSamePrefix(tmp_name, GET_PC_NAME(d->character), + std::min(kMinNameLength, + native_text::char_count(GET_PC_NAME(d->character)) - 1))) { d->character->player_data.PNames[cur.ecase] = std::string(utils::CAP(tmp_name)); if (step < kLast) { const auto &next = kSteps[step + 1]; @@ -1541,7 +1565,8 @@ static void HandleGetKeytable(DescriptorData *d, char *argument) { static void HandleNameConfirm(DescriptorData *d, char *argument) { char buffer[kMaxStringLength]; - if (UPPER(*argument) == 'Y' || UPPER(*argument) == 'Д') { + if (native_text::first_char_code_upper(argument) == 'Y' + || native_text::first_char_code_upper(argument) == rus::kDeUpper) { if (ban->IsBanned(d->host) >= BanList::BAN_NEW) { sprintf(buffer, "Попытка создания персонажа %s отклонена для [%s] (siteban)", GET_PC_NAME(d->character), d->host); @@ -1579,7 +1604,8 @@ static void HandleNameConfirm(DescriptorData *d, char *argument) { d->state = EConState::kQsex; return; - } else if (UPPER(*argument) == 'N' || UPPER(*argument) == 'Н') { + } else if (native_text::first_char_code_upper(argument) == 'N' + || native_text::first_char_code_upper(argument) == rus::kEnUpper) { iosystem::write_to_output("Итак, чего изволите? Учтите, бананов нет :)\r\n" "Имя : ", d); d->character->SetCharAliases(nullptr); d->state = EConState::kGetName; @@ -1686,12 +1712,12 @@ static void HandleQuerySex(DescriptorData *d, char *argument) { return; } - switch (UPPER(*argument)) { - case 'М': + switch (native_text::first_char_code_upper(argument)) { + case rus::kEmUpper: case 'M': d->character->set_sex(EGender::kMale); break; - case 'Ж': + case rus::kZheUpper: case 'F': d->character->set_sex(EGender::kFemale); break; @@ -1715,9 +1741,9 @@ static void HandleQueryReligion(DescriptorData *d, char *argument) { return; } - switch (UPPER(*argument)) { - case 'Я': - case 'З': + switch (native_text::first_char_code_upper(argument)) { + case rus::kYaUpper: + case rus::kZeUpper: case 'P': if (class_religion[to_underlying(d->character->GetClass())] == kReligionMono) { iosystem::write_to_output("Персонаж выбранной вами профессии не желает быть язычником!\r\n" @@ -1727,7 +1753,7 @@ static void HandleQueryReligion(DescriptorData *d, char *argument) { GET_RELIGION(d->character) = kReligionPoly; break; - case 'Х': + case rus::kHaUpper: case 'C': if (class_religion[to_underlying(d->character->GetClass())] == kReligionPoly) { iosystem::write_to_output("Персонажу выбранной вами профессии противно христианство!\r\n" @@ -2058,9 +2084,9 @@ static void HandleResetReligion(DescriptorData *d, char *argument) { return; } - switch (UPPER(*argument)) { - case 'Я': - case 'З': + switch (native_text::first_char_code_upper(argument)) { + case rus::kYaUpper: + case rus::kZeUpper: case 'P': if (class_religion[to_underlying(d->character->GetClass())] == kReligionMono) { iosystem::write_to_output("Персонаж выбранной вами профессии не желает быть язычником!\r\n" @@ -2070,7 +2096,7 @@ static void HandleResetReligion(DescriptorData *d, char *argument) { GET_RELIGION(d->character) = kReligionPoly; break; - case 'Х': + case rus::kHaUpper: case 'C': if (class_religion[to_underlying(d->character->GetClass())] == kReligionPoly) { iosystem::write_to_output("Персонажу выбранной вами профессии противно христианство!\r\n" diff --git a/src/engine/ui/mapsystem.cpp b/src/engine/ui/mapsystem.cpp index dc2b4744ca..9852aa6860 100644 --- a/src/engine/ui/mapsystem.cpp +++ b/src/engine/ui/mapsystem.cpp @@ -615,11 +615,16 @@ void print_map(CharData *ch, CharData *imm) { } } - if (found && start_line < 0) { - start_line = i; - } else if (!found && start_line > 0) { - end_line = i; - break; + // Ищем именно первую и последнюю непустую строку, а не первый разрыв: в строке + // посередине карты может не оказаться ни одной клетки (комнаты редко стоят плотным + // прямоугольником), и прежний код обрубал карту прямо там. Чем больше глубина, тем + // вероятнее разрыв, поэтому "карта богов" с глубиной 25 выглядела не крупнее обычной. + // Ровно та же правка уже сделана для правой границы по столбцам. + if (found) { + if (start_line < 0) { + start_line = i; + } + end_line = static_cast(i) + 1; } } diff --git a/src/engine/ui/modify.cpp b/src/engine/ui/modify.cpp index 11fe7132e3..d47afa9b00 100644 --- a/src/engine/ui/modify.cpp +++ b/src/engine/ui/modify.cpp @@ -13,10 +13,12 @@ ************************************************************************ */ #include +#include "utils/russian_keys.h" #include "engine/db/player_index.h" #include #include "modify.h" +#include "utils/native_text.h" #include "engine/olc/vedun/vedun.h" #include "interpreter.h" #include "engine/core/target_resolver.h" @@ -652,11 +654,11 @@ void string_add(DescriptorData *d, char *str) { if (!d->writer->get_string()) { if (strlen(str) + 3 > d->max_str) { SendMsgToChar("Слишком длинная строка - усечена.\r\n", d->character.get()); - strcpy(&str[d->max_str - 3], "\r\n"); + strcpy(&str[native_text::truncate_offset(str, d->max_str - 3)], "\r\n"); d->writer->set_string(str); } else if (EConState::kWriteMod == d->state && strlen(str) + 3 > 80) { SendMsgToChar("Слишком длинная строка - усечена.\r\n", d->character.get()); - str[80 - 3] = '\0'; + str[native_text::truncate_offset(str, 80 - 3)] = '\0'; d->writer->set_string(str); } else { d->writer->set_string(str); @@ -664,7 +666,7 @@ void string_add(DescriptorData *d, char *str) { } else { if (EConState::kWriteMod == d->state && strlen(str) + 3 > 80) { SendMsgToChar("Слишком длинная строка - усечена.\r\n", d->character.get()); - str[80 - 3] = '\0'; + str[native_text::truncate_offset(str, 80 - 3)] = '\0'; } if (strlen(str) + d->writer->length() + 3 > d->max_str) // \r\n\0 // @@ -928,8 +930,11 @@ void do_featset(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { } // Locate the last quote and lowercase the magic words (if any) // - for (qend = 1; argument[qend] && argument[qend] != '\''; qend++) - argument[qend] = LOWER(argument[qend]); + // Шагаем по символу: под UTF-8 русская буква занимает два байта (issue #3681). + for (qend = 1; argument[qend] && argument[qend] != '\''; + qend += static_cast(native_text::char_bytes(argument + qend))) { + native_text::copy_lower_char(argument + qend, argument + qend); + } if (argument[qend] != '\'') { SendMsgToChar("Название способности должно быть заключено в символы : ''\r\n", ch); @@ -1038,8 +1043,10 @@ void do_skillset(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { } // Locate the last quote and lowercase the magic words (if any) - for (qend = 1; argument[qend] && argument[qend] != '\''; qend++) { - argument[qend] = LOWER(argument[qend]); + // Шагаем по символу: под UTF-8 русская буква занимает два байта (issue #3681). + for (qend = 1; argument[qend] && argument[qend] != '\''; + qend += static_cast(native_text::char_bytes(argument + qend))) { + native_text::copy_lower_char(argument + qend, argument + qend); } if (argument[qend] != '\'') { @@ -1188,9 +1195,14 @@ char *next_page(char *str, CharData *ch) { // * We need to check here and see if we are over the page width, // * and if so, compensate by going to the begining of the next line. - else if ((ch)->player_specials->saved.stringLength && ++col > (ch)->player_specials->saved.stringLength) { - col = 1; - line++; + // * A multibyte character counts as one column; skip its trailing bytes so + // * they are not counted again (native_text::char_bytes == 1 under KOI8-R). + else if ((ch)->player_specials->saved.stringLength) { + if (++col > (ch)->player_specials->saved.stringLength) { + col = 1; + line++; + } + str += native_text::char_bytes(str) - 1; } } } @@ -1263,7 +1275,8 @@ void show_string(DescriptorData *d, char *input) { any_one_arg(input, buf); //* Q is for quit. :) - if (LOWER(*buf) == 'q' || LOWER(*buf) == 'к') { + if (native_text::first_char_code_lower(buf) == 'q' + || native_text::first_char_code_lower(buf) == rus::kKa) { free(d->showstr_vector); d->showstr_count = 0; if (d->showstr_head) { @@ -1275,12 +1288,14 @@ void show_string(DescriptorData *d, char *input) { } // R is for refresh, so back up one page internally so we can display // it again. - else if (LOWER(*buf) == 'r' || LOWER(*buf) == 'п') { + else if (native_text::first_char_code_lower(buf) == 'r' + || native_text::first_char_code_lower(buf) == rus::kPe) { d->showstr_page = MAX(0, d->showstr_page - 1); } // B is for back, so back up two pages internally so we can display the // correct page here. - else if (LOWER(*buf) == 'b' || LOWER(*buf) == 'н') { + else if (native_text::first_char_code_lower(buf) == 'b' + || native_text::first_char_code_lower(buf) == rus::kEn) { d->showstr_page = MAX(0, d->showstr_page - 2); } // Feature to 'goto' a page. Just type the number of the page and you diff --git a/src/engine/ui/objects_filter.cpp b/src/engine/ui/objects_filter.cpp index ab80d07e9e..cdd217ac94 100644 --- a/src/engine/ui/objects_filter.cpp +++ b/src/engine/ui/objects_filter.cpp @@ -6,6 +6,8 @@ */ #include "objects_filter.h" +#include "utils/russian_keys.h" +#include "utils/native_text.h" #include "gameplay/mechanics/sight.h" #include "gameplay/economics/exchange.h" @@ -835,46 +837,49 @@ bool ParseFilter::parse_filter(const CharData *ch, ParseFilter &filter, const ch return false; } while (*argument) { - switch (*argument) { - case 'И': argument = one_argument(++argument, buf_tmp); + // Буква фильтра съедается целиком: first_char_code читает символ, а шаг должен быть на + // столько же байт. В UTF-8 русская буква занимает два, и "++argument" оставлял в потоке + // хвостовой байт -- он приклеивался к значению, и "базар ф Имеч" искал не "меч". + switch (native_text::first_char_code(argument)) { + case rus::kIUpper: argument = one_argument(argument + native_text::char_bytes(argument), buf_tmp); if (strlen(buf_tmp) == 0) { SendMsgToChar("Укажите имя предмета.\r\n", ch); return false; } filter.name = buf_tmp; break; - case 'Т': argument = one_argument(++argument, buf_tmp); + case rus::kTeUpper: argument = one_argument(argument + native_text::char_bytes(argument), buf_tmp); if (!filter.init_type(buf_tmp)) { SendMsgToChar("Неверный тип предмета.\r\n", ch); return false; } break; - case 'С': argument = one_argument(++argument, buf_tmp); + case rus::kEsUpper: argument = one_argument(argument + native_text::char_bytes(argument), buf_tmp); if (!filter.init_state(buf_tmp)) { SendMsgToChar("Неверное состояние предмета.\r\n", ch); return false; } break; - case 'О': argument = one_argument(++argument, buf_tmp); + case rus::kOUpper: argument = one_argument(argument + native_text::char_bytes(argument), buf_tmp); if (!filter.init_wear(buf_tmp)) { SendMsgToChar("Неверное место одевания предмета.\r\n", ch); return false; } break; - case 'Ц': argument = one_argument(++argument, buf_tmp); + case rus::kTseUpper: argument = one_argument(argument + native_text::char_bytes(argument), buf_tmp); if (!filter.init_cost(buf_tmp)) { SendMsgToChar("Неверный формат в фильтре: Ц<цена><+->.\r\n", ch); return false; } break; - case 'К': argument = one_argument(++argument, buf_tmp); + case rus::kKaUpper: argument = one_argument(argument + native_text::char_bytes(argument), buf_tmp); if (!filter.init_weap_class(buf_tmp)) { SendMsgToChar("Неверный класс оружия.\r\n", ch); return false; } break; - case 'А': { - argument = one_argument(++argument, buf_tmp); + case rus::kAUpper: { + argument = one_argument(argument + native_text::char_bytes(argument), buf_tmp); size_t len = strlen(buf_tmp); if (len == 0) { SendMsgToChar("Укажите аффект предмета.\r\n", ch); @@ -888,38 +893,38 @@ bool ParseFilter::parse_filter(const CharData *ch, ParseFilter &filter, const ch return false; } break; - } // case 'А' - case 'Р':// стоимость ренты - argument = one_argument(++argument, buf_tmp); + } // case rus::kAUpper + case rus::kErUpper:// стоимость ренты + argument = one_argument(argument + native_text::char_bytes(argument), buf_tmp); if (!filter.init_rent(buf_tmp)) { SendMsgToChar("Неверный формат в фильтре: Р<стоимость><+->.\r\n", ch); return false; } break; - case 'М':// количество мортов - argument = one_argument(++argument, buf_tmp); + case rus::kEmUpper:// количество мортов + argument = one_argument(argument + native_text::char_bytes(argument), buf_tmp); if (!filter.init_remorts(buf_tmp)) { SendMsgToChar("Неверный формат в фильтре: М<количество мортов><+->.\r\n", ch); return false; } break; - case 'У':// умения - argument = one_argument(++argument, buf_tmp); + case rus::kUUpper:// умения + argument = one_argument(argument + native_text::char_bytes(argument), buf_tmp); if (!filter.init_skill(buf_tmp)) { SendMsgToChar("Неверное умение.\r\n", ch); return false; } break; - case 'В':// имя выставившего на базаре - argument = one_argument(++argument, buf_tmp); + case rus::kVeUpper:// имя выставившего на базаре + argument = one_argument(argument + native_text::char_bytes(argument), buf_tmp); if (filter_type != EXCHANGE) { SendMsgToChar("Только для базара.\r\n", ch); return false; } owner = buf_tmp; break; - case 'П':// профессия (отсечь предметы запрещенные данному классу) - argument = one_argument(++argument, buf_tmp); + case rus::kPeUpper:// профессия (отсечь предметы запрещенные данному классу) + argument = one_argument(argument + native_text::char_bytes(argument), buf_tmp); if (!filter.init_profession(buf_tmp)) { SendMsgToChar("Неверное название профессии.\r\n", ch); return false; diff --git a/src/engine/ui/table_wrapper.h b/src/engine/ui/table_wrapper.h index 94d684c471..0fae678108 100644 --- a/src/engine/ui/table_wrapper.h +++ b/src/engine/ui/table_wrapper.h @@ -41,9 +41,17 @@ using Color = fort::color; using TextAlign = fort::text_align; /** - * Таблица в стандартной, не unicode кодировке. + * Базовый тип таблицы выбирается под нативную кодировку движка (issue #3681). + * libfort считает ширину ячейки по-разному: char_table меряет байтами, utf8_table - кодовыми + * точками. Под KOI8-R (1 байт = 1 символ) верен первый, под UTF-8 - второй; при неверном выборе + * колонки с русским текстом съезжают вдвое. */ -class Table : public fort::char_table { +using TableBase = fort::utf8_table; + +/** + * Таблица в нативной кодировке движка. + */ +class Table : public TableBase { public: void SetColumnAlign(std::size_t column_index, TextAlign align) { this->column(column_index).set_cell_text_align(align); diff --git a/src/gameplay/affects/affect_data.cpp b/src/gameplay/affects/affect_data.cpp index 0b6a6641a5..e0d99d1b5c 100644 --- a/src/gameplay/affects/affect_data.cpp +++ b/src/gameplay/affects/affect_data.cpp @@ -53,8 +53,8 @@ void EmitAffectEvent(const char *kind, const CharData *ch, ev.name = kind; ev.ts_unix_ms = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); - ev.attrs["target_name"] = observability::EngineStringToUtf8( - GET_NAME(ch) ? GET_NAME(ch) : ""); + ev.attrs["target_name"] = + GET_NAME(ch) ? GET_NAME(ch) : ""; ev.attrs["duration"] = static_cast(af.duration); ev.attrs["modifier"] = static_cast(af.modifier); ev.attrs["location"] = static_cast(af.location); diff --git a/src/gameplay/ai/subcmd_resolver.cpp b/src/gameplay/ai/subcmd_resolver.cpp index 59656db552..800cda54ad 100644 --- a/src/gameplay/ai/subcmd_resolver.cpp +++ b/src/gameplay/ai/subcmd_resolver.cpp @@ -5,7 +5,7 @@ #include "engine/core/comm.h" // SendMsgToChar #include "utils/mud_string.h" // one_argument #include "utils/utils.h" // skip_spaces, kMaxInputLength -#include "utils/utils_string.h" // str_cmp, strn_cmp +#include "utils/utils_string.h" // str_cmp, IsAbbr #include #include @@ -48,7 +48,7 @@ const SubCmdResolver::Row *SubCmdResolver::Resolve(const char *word, bool &ambig const size_t len = std::strlen(word); for (const auto &row : rows_) { for (const auto &name : row.names) { - if (len <= name.size() && !strn_cmp(word, name.c_str(), len)) { + if (len <= name.size() && utils::IsAbbr(word, name.c_str())) { if (match && match != &row) { ambiguous = true; return nullptr; diff --git a/src/gameplay/ai/subcmd_resolver.h b/src/gameplay/ai/subcmd_resolver.h index 703185dc25..bd8e78d5be 100644 --- a/src/gameplay/ai/subcmd_resolver.h +++ b/src/gameplay/ai/subcmd_resolver.h @@ -15,7 +15,7 @@ class CharData; // row), so the hint can never drift from the actual subcommands. Strings live in code for now; later // they move to special_msg.xml and this table is built from there -- the API stays the same. // -// Lookup is currently a linear, case-insensitive scan (str_cmp/strn_cmp, KOI8-aware) -- correct and +// Lookup is currently a linear, case-insensitive scan (str_cmp/IsAbbr, KOI8-aware) -- correct and // cheap for the handful of subcommands a spec proc has; the same API can be backed by a shared // prefix-tree later (and reused for socials/command abbreviation). class SubCmdResolver { diff --git a/src/gameplay/clans/chest_saver.cpp b/src/gameplay/clans/chest_saver.cpp index 77d977392e..d8261ff2f5 100644 --- a/src/gameplay/clans/chest_saver.cpp +++ b/src/gameplay/clans/chest_saver.cpp @@ -1,5 +1,6 @@ // Part of Bylins http://www.mud.ru +#include "utils/native_text.h" #include "chest_saver.h" #include "house.h" @@ -52,7 +53,11 @@ bool save_one_clan_chest(ObjData *chest, const std::string &filename) { } const auto written = out.tellp(); const auto contents = out.str(); - file.write(contents.data(), static_cast(written)); + // Граница записи: на диск уходит кодировка мира (сейчас KOI8-R), зеркально + // чтению -- иначе первое же сохранение переводит файл в UTF-8, и откат на + // прежнюю сборку становится невозможен (issue #3681). + const std::string on_disk = native_text::to_disk(contents.substr(0, static_cast(written))); + file.write(on_disk.data(), static_cast(on_disk.size())); file.close(); return true; } diff --git a/src/gameplay/clans/house.cpp b/src/gameplay/clans/house.cpp index 5de23f084a..4a5b1795b8 100644 --- a/src/gameplay/clans/house.cpp +++ b/src/gameplay/clans/house.cpp @@ -4,7 +4,10 @@ * (c) 2005 Krodo * ******************************************************************************/ +#include "utils/utils_string.h" #include "house.h" +#include "utils/russian_keys.h" +#include "utils/native_text.h" #include "engine/db/player_index.h" #include "gameplay/economics/currencies.h" #include "utils/utils_encoding.h" @@ -130,8 +133,10 @@ void prepare_write_mod(CharData *ch, std::string ¶m) { * и перевод всего слова в нижний регистр. */ void check_rank(std::string &rank) { - if (rank.size() > MAX_RANK_LENGHT) { - rank = rank.substr(0, MAX_RANK_LENGHT); + // Предел -- в символах (так и сказано игроку), а с UTF-8 кириллица занимает по два байта: + // по size() десятибуквенное звание резалось до пяти букв, да ещё и посреди символа (issue #3681). + if (native_text::char_count(rank) > MAX_RANK_LENGHT) { + rank = rank.substr(0, native_text::char_offset(rank, MAX_RANK_LENGHT)); } utils::ConvertToLow(rank); } @@ -190,8 +195,10 @@ Clan::~Clan() { } // релоад одного отдельного клана, абр. указывать на латинице! void Clan::ClanReload(const std::string &index) { - std::ifstream file(LIB_CLANS "index"); - if (!file.is_open()) { + // Граница чтения: файл лежит на диске в кодировке мира, поднимаем его целиком -- + // дальше разбор идёт по нативному тексту и не меняется (issue #3681). + std::istringstream file(native_text::read_data_file(LIB_CLANS "index")); + if (file.str().empty()) { log("Error open file: %s! (%s %s %d)", LIB_CLANS "index", __FILE__, __func__, __LINE__); return; } @@ -199,7 +206,6 @@ void Clan::ClanReload(const std::string &index) { std::list clanIndex; while (file >> buffer) clanIndex.push_back(buffer); - file.close(); // ищем наш клан for (const auto &it : clanIndex) { if (it == index) { @@ -224,8 +230,10 @@ void Clan::ClanLoadSingle(const std::string &index) { const auto tempClan = std::make_shared(); std::string filename = LIB_CLANS + index + "/" + index; - std::ifstream file(filename.c_str()); - if (!file.is_open()) { + // Граница чтения: файл лежит на диске в кодировке мира, поднимаем его целиком -- + // дальше разбор идёт по нативному тексту и не меняется (issue #3681). + std::istringstream file(native_text::read_data_file(filename.c_str())); + if (file.str().empty()) { log("Error open file: %s! (%s %s %d)", filename.c_str(), __FILE__, __func__, __LINE__); return; } @@ -439,7 +447,7 @@ void Clan::ClanLoadSingle(const std::string &index) { log("Owner %ld is no longer exist (%s).", unique, filename.c_str()); break; } - tempMember->name[0] = UPPER(tempMember->name[0]); + native_text::capitalize_first(tempMember->name); tempMember->rank_num = 0; tempMember->money = money; tempMember->exp = exp; @@ -475,7 +483,7 @@ void Clan::ClanLoadSingle(const std::string &index) { log("Member %ld is no longer exist (%s).", unique, filename.c_str()); continue; } - tempMember->name[0] = UPPER(tempMember->name[0]); + native_text::capitalize_first(tempMember->name); tempMember->rank_num = rank; tempMember->money = money; tempMember->exp = exp; @@ -485,7 +493,6 @@ void Clan::ClanLoadSingle(const std::string &index) { } } - file.close(); // тут нужно проверить наличие критичных для клана полей // т.к. загрузка без привязки к положению в файле - что-то может не проинициализироваться @@ -537,8 +544,9 @@ void Clan::ClanLoadSingle(const std::string &index) { } // подгружаем пкл/дрл - std::ifstream pkFile((filename + ".pkl").c_str()); - if (pkFile.is_open()) { + // Граница чтения: pk-лог лежит на диске в кодировке мира (issue #3681). + std::istringstream pkFile(native_text::read_data_file(filename + ".pkl")); + if (!pkFile.str().empty()) { int author = 0; while (pkFile >> author) { int victim = 0; @@ -572,11 +580,11 @@ void Clan::ClanLoadSingle(const std::string &index) { tempClan->frList[victim] = tempRecord; } } - pkFile.close(); } //подгружаем кланстафф - std::ifstream stuffFile((filename + ".stuff").c_str()); - if (stuffFile.is_open()) { + // Граница чтения: названия именных вещей дружины лежат в кодировке мира (issue #3681). + std::istringstream stuffFile(native_text::read_data_file(filename + ".stuff")); + if (!stuffFile.str().empty()) { int i; while (stuffFile >> i) { ClanStuffName temp; @@ -636,8 +644,10 @@ void Clan::ClanLoad() { Clan::ClanList.clear(); // файл со списком кланов - std::ifstream file(LIB_CLANS "index"); - if (!file.is_open()) { + // Граница чтения: файл лежит на диске в кодировке мира, поднимаем его целиком -- + // дальше разбор идёт по нативному тексту и не меняется (issue #3681). + std::istringstream file(native_text::read_data_file(LIB_CLANS "index")); + if (file.str().empty()) { log("Error open file: %s! (%s %s %d)", LIB_CLANS "index", __FILE__, __func__, __LINE__); return; } @@ -645,7 +655,6 @@ void Clan::ClanLoad() { std::list clanIndex; while (file >> buffer) clanIndex.push_back(buffer); - file.close(); // собственно грузим кланы for (const auto &it : clanIndex) { Clan::ClanLoadSingle(it); @@ -793,8 +802,11 @@ bool write_if_changed(const std::string &filename, const std::string &contents, log("Error open file: %s! (%s %s %d)", filename.c_str(), __FILE__, __func__, __LINE__); return false; } - file.write(contents.data(), static_cast(contents.size())); - file.close(); + // Граница записи: на диск уходит кодировка мира (сейчас KOI8-R), зеркально + // чтению -- иначе первое же сохранение переводит файл в UTF-8, и откат на + // прежнюю сборку становится невозможен (issue #3681). + const std::string on_disk = native_text::to_disk(contents); + file.write(on_disk.data(), static_cast(on_disk.size())); cache = contents; return true; @@ -1003,7 +1015,7 @@ void Clan::HouseInfo(CharData *ch) { for (const auto &it : temp_list) { if (temp != ranks[it->rank_num]) { std::string rnk = ranks[it->rank_num]; - rnk[0] = UPPER(rnk[0]); + native_text::capitalize_first(rnk); if (temp == "") { buffer << rnk << ": "; @@ -1128,7 +1140,7 @@ void Clan::HouseAdd(CharData *ch, std::string &buffer) { return; } std::string name = buffer2; - name[0] = UPPER(name[0]); + native_text::capitalize_first(name); if (unique == ch->get_uid()) { SendMsgToChar("Сам себя повысил, самому себе вынес благодарность?\r\n", ch); return; @@ -1320,7 +1332,7 @@ void Clan::remove_member(const ClanMembersList::key_type &key, char *reason) { if (d->character && CLAN(d->character) && CLAN(d->character)->GetRent() == this->GetRent()) { - name[0] = UPPER(name[0]); + native_text::capitalize_first(name); SendMsgToChar(d->character.get(), "%s более не является членом вашей дружины.\r\n", name.c_str()); } } @@ -1438,7 +1450,7 @@ void Clan::hcon_outcast(CharData *ch, std::string &buffer) { char tmpstr[kMaxInputLength]; sprintf(tmpstr, "Богом %s", GET_NAME(ch)); clan->remove_member(member_uid, tmpstr); - name[0] = UPPER(name[0]); + native_text::capitalize_first(name); SendMsgToChar(ch, "%s исключен(a) из дружины '%s'.\r\n", name.c_str(), clan->name.c_str()); return; } @@ -1874,7 +1886,8 @@ void Clan::hcontrol_rank(CharData *ch, std::string &text) { SendMsgToChar(HCONTROL_FORMAT, ch); return; } - if (rank_male.size() > MAX_RANK_LENGHT || rank_female.size() > MAX_RANK_LENGHT) { + if (native_text::char_count(rank_male) > MAX_RANK_LENGHT + || native_text::char_count(rank_female) > MAX_RANK_LENGHT) { SendMsgToChar(ch, "Звание не должно быть длиннее %d символов.\r\n", MAX_RANK_LENGHT); return; } @@ -2085,7 +2098,7 @@ void Clan::HcontrolBuild(CharData *ch, std::string &buffer) { tempClan->chest_room = rent; tempClan->guard = guard; // пишем воеводу - owner[0] = UPPER(owner[0]); + native_text::capitalize_first(owner); tempClan->owner = owner; const auto tempMember = std::make_shared(); tempMember->name = owner; @@ -2446,9 +2459,11 @@ void Clan::save_chest() { log("Save obj: %s", this->abbrev.c_str()); ObjSaveSync::check(this->GetRent(), ObjSaveSync::CLAN_SAVE); + // Имя каталога дружины строится из байтов KOI8-R, как и до миграции: под UTF-8 байтовый + // AtoL резал русскую букву пополам, и добро дружины уезжало в каталог с другим именем + // (issue #3681). std::string buffer = this->abbrev; - for (unsigned i = 0; i != buffer.length(); ++i) - buffer[i] = LOWER(codepages::AtoL(buffer[i])); + CreateFileName(buffer); std::string filename = LIB_CLANS + buffer + "/" + buffer + ".obj"; for (auto chest : world[GetRoomRnum(this->chest_room)]->contents) { if (Clan::is_clan_chest(chest)) { @@ -2464,8 +2479,11 @@ void Clan::save_chest() { log("Error open file: %s! (%s %s %d)", filename.c_str(), __FILE__, __func__, __LINE__); return; } - file << out.rdbuf(); - file.close(); + // Граница записи: на диск уходит кодировка мира (сейчас KOI8-R), зеркально + // чтению -- иначе первое же сохранение переводит файл в UTF-8, и откат на + // прежнюю сборку становится невозможен (issue #3681). + const std::string on_disk = native_text::to_disk(out.str()); + file.write(on_disk.data(), static_cast(on_disk.size())); break; } } @@ -2517,9 +2535,7 @@ void Clan::ChestLoad() { for (ClanListType::const_iterator clan = Clan::ClanList.begin(); clan != Clan::ClanList.end(); ++clan) { buffer = (*clan)->abbrev; - for (unsigned i = 0; i != buffer.length(); ++i) { - buffer[i] = LOWER(codepages::AtoL(buffer[i])); - } + CreateFileName(buffer); std::string filename = LIB_CLANS + buffer + "/" + buffer + ".obj"; //лоадим сундук. в зонах его лоадить не нужно. @@ -2554,8 +2570,18 @@ void Clan::ChestLoad() { } fclose(fl); + // Граница чтения: сундук лежит на диске в кодировке мира, в память идёт нативным -- + // зеркало к to_disk в ChestSaver. Без этого имена и метки вещей в сундуке уезжают + // в транслит при первом же сохранении дружины (issue #3681). + { + const std::string native = native_text::from_disk_text(std::string(databuf, static_cast(fsize))); + delete[] databuf; + databuf = new char[native.size() + 1]; + std::memcpy(databuf, native.data(), native.size()); + databuf[native.size()] = '\0'; + } + data = databuf; - *(data + fsize) = '\0'; for (fsize = 0; *data && *data != '$'; fsize++) { const auto obj = read_one_object_new(&data, &error); @@ -2620,9 +2646,7 @@ void Clan::ChestUpdate() { // * Запись сообщения дружины в файл и поле клана. void Clan::write_mod(const std::string &arg) { std::string abbrev = this->get_abbrev(); - for (unsigned i = 0; i != abbrev.length(); ++i) { - abbrev[i] = LOWER(codepages::AtoL(abbrev[i])); - } + CreateFileName(abbrev); std::string filename = LIB_CLANS + abbrev + "/" + abbrev + ".mod"; std::ofstream file(filename.c_str()); @@ -2630,8 +2654,10 @@ void Clan::write_mod(const std::string &arg) { log("Error open file: %s! (%s %s %d)", filename.c_str(), __FILE__, __func__, __LINE__); return; } - file << arg; - file.close(); + // Граница записи: на диск уходит кодировка мира (сейчас KOI8-R), зеркально + // чтению -- иначе первое же сохранение переводит файл в UTF-8, и откат на + // прежнюю сборку становится невозможен (issue #3681). + file << native_text::to_disk(arg); mod_text = arg; } @@ -2652,8 +2678,10 @@ void Clan::load_mod() { std::string abbrev = this->get_file_abbrev(); std::string filename = LIB_CLANS + abbrev + "/" + abbrev + ".mod"; - std::ifstream file(filename.c_str(), std::ios::binary); - if (!file.is_open()) { + // Граница чтения: файл лежит на диске в кодировке мира, поднимаем его целиком -- + // дальше разбор идёт по нативному тексту и не меняется (issue #3681). + std::istringstream file(native_text::read_data_file(filename.c_str())); + if (file.str().empty()) { log("Error open file: %s! (%s %s %d)", filename.c_str(), __FILE__, __func__, __LINE__); return; } @@ -2776,9 +2804,9 @@ void Clan::Manage(DescriptorData *d, const char *arg) { switch (d->clan_olc->mode) { case CLAN_MAIN_MENU: - switch (*arg) { - case 'в': - case 'В': + switch (native_text::first_char_code(arg)) { + case rus::kVe: + case rus::kVeUpper: case 'q': case 'Q': // есть вариант, что за время в олц в клане изменят кол-во званий @@ -2858,9 +2886,9 @@ void Clan::Manage(DescriptorData *d, const char *arg) { break; case CLAN_PRIVILEGE_MENU: - switch (*arg) { - case 'в': - case 'В': + switch (native_text::first_char_code(arg)) { + case rus::kVe: + case rus::kVeUpper: case 'q': case 'Q': // выход в общее меню @@ -2903,11 +2931,11 @@ void Clan::Manage(DescriptorData *d, const char *arg) { break; case CLAN_SAVE_MENU: - switch (*arg) { + switch (native_text::first_char_code(arg)) { case 'y': case 'Y': - case 'д': - case 'Д': d->clan_olc->clan->privileges.clear(); + case rus::kDe: + case rus::kDeUpper: d->clan_olc->clan->privileges.clear(); d->clan_olc->clan->privileges = d->clan_olc->privileges; d->clan_olc.reset(); // Clan::ClanSave(); @@ -2917,8 +2945,8 @@ void Clan::Manage(DescriptorData *d, const char *arg) { case 'n': case 'N': - case 'н': - case 'Н': d->clan_olc.reset(); + case rus::kEn: + case rus::kEnUpper: d->clan_olc.reset(); d->state = EConState::kPlaying; SendMsgToChar("Редактирование отменено.\r\n", d->character.get()); return; @@ -2933,9 +2961,9 @@ void Clan::Manage(DescriptorData *d, const char *arg) { break; case CLAN_ADDALL_MENU: - switch (*arg) { - case 'в': - case 'В': + switch (native_text::first_char_code(arg)) { + case rus::kVe: + case rus::kVeUpper: case 'q': case 'Q': // выход в общее меню с изменением всех званий @@ -2987,9 +3015,9 @@ void Clan::Manage(DescriptorData *d, const char *arg) { break; case CLAN_DELALL_MENU: - switch (*arg) { - case 'в': - case 'В': + switch (native_text::first_char_code(arg)) { + case rus::kVe: + case rus::kVeUpper: case 'q': case 'Q': // выход в общее меню с изменением всех званий @@ -3500,7 +3528,7 @@ void Clan::HouseOwner(CharData *ch, std::string &buffer) { else if (CLAN(d->character) && CLAN(ch) != CLAN(d->character)) SendMsgToChar("Вы не можете передать свои права члену другой дружины.\r\n", ch); else { - buffer2[0] = UPPER(buffer2[0]); + native_text::capitalize_first(buffer2); // воевода идет рангом ниже this->m_members.set_rank(ch->get_uid(), 1); Clan::SetClanData(ch); @@ -3660,7 +3688,6 @@ void Clan::HouseStat(CharData *ch, std::string &buffer) { // т.к. в кои8-р русские буквы не попорядку const char *pSortAlph = "яюэьыъщшчцхфутсрпонмлкизжёедгвба"; // первая буква имени - char pcFirstChar[2]; // для избежания путаницы с именами фильтр начинается со знака "!" // формат команды: @@ -3772,11 +3799,23 @@ void Clan::HouseStat(CharData *ch, std::string &buffer) { case SORT_STAT_BY_LOGON: lSortParam = GetLastlogonByUnique(it.first); break; case SORT_STAT_BY_NAME: { - pcFirstChar[0] = LOWER(it.second->name[0]); - pcFirstChar[1] = '\0'; - char const *pTmp = strpbrk(pSortAlph, pcFirstChar); - if (pTmp) lSortParam = pTmp - pSortAlph; // индекс первой буквы в массиве - else lSortParam = pcFirstChar[0]; // или не русская буква или я хз + // Индекс первой БУКВЫ имени в алфавите. Под UTF-8 буква занимает два байта, и + // strpbrk по одному байту находил бы что попало, а то и вовсе ничего + // (issue #3681). Идём по алфавиту символ за символом. + char first_letter[8] = {0}; + native_text::copy_lower_char(it.second->name.c_str(), first_letter); + lSortParam = -1; + long letter_index = 0; + for (const char *p = pSortAlph; *p; p += native_text::char_bytes(p), ++letter_index) { + if (native_text::chars_equal_ci(p, first_letter)) { + lSortParam = letter_index; + break; + } + } + if (lSortParam < 0) { + // не русская буква -- пусть уедет в конец, но в стабильном порядке + lSortParam = static_cast(native_text::first_char_code(first_letter)); + } break; } // на всякий случай @@ -4249,9 +4288,7 @@ std::string Clan::get_remember(unsigned int num, int flag) const { std::string Clan::get_file_abbrev() const { std::string text = this->get_abbrev(); - for (unsigned i = 0; i != text.length(); ++i) { - text[i] = LOWER(codepages::AtoL(text[i])); - } + CreateFileName(text); return text; } @@ -4567,8 +4604,7 @@ void init_xhelp() { " Список сайтов дружин:\r\n\r\n"; for (const auto &i : Clan::ClanList) { - out << " $COLORW" << std::setw(7) << std::left - << i->GetAbbrev() << "$COLORn -- $COLORC" + out << " $COLORW" << fmt::format("{:<7}", i->GetAbbrev()) << "$COLORn -- $COLORC" << (i->get_web_url().empty() ? "$COLORW[ НЕТ ИНФОРМАЦИИ ]" : i->get_web_url()) << "$COLORn\r\n"; } diff --git a/src/gameplay/clans/house_exp.cpp b/src/gameplay/clans/house_exp.cpp index a50b0e0bd0..6c86a44e9e 100644 --- a/src/gameplay/clans/house_exp.cpp +++ b/src/gameplay/clans/house_exp.cpp @@ -2,6 +2,8 @@ // Copyright (c) 2009 Krodo // Part of Bylins http://www.mud.ru +#include +#include "utils/native_text.h" #include "house_exp.h" #include "utils/grammar/gender.h" @@ -55,14 +57,20 @@ void ClanExp::save(const std::string &abbrev) const { for (ExpListType::const_iterator it = list_.begin(); it != list_.end(); ++it) { out << *it << "\n"; } - file << out.rdbuf(); + // Граница записи: на диск уходит кодировка мира (сейчас KOI8-R), зеркально + // чтению -- иначе первое же сохранение переводит файл в UTF-8, и откат на + // прежнюю сборку становится невозможен (issue #3681). + const std::string on_disk = native_text::to_disk(out.str()); + file.write(on_disk.data(), static_cast(on_disk.size())); } // * Загрузка списка экспы и буффера конкретного клана (по аббревиатуре). void ClanExp::load(const std::string &abbrev) { std::string filename = LIB_CLANS + abbrev + "/" + abbrev + ".exp"; - std::ifstream file(filename.c_str()); - if (!file.is_open()) { + // Граница чтения: файл лежит на диске в кодировке мира, поднимаем его целиком -- + // дальше разбор идёт по нативному тексту и не меняется (issue #3681). + std::istringstream file(native_text::read_data_file(filename.c_str())); + if (file.str().empty()) { log("Error open file: %s! (%s %s %d)", filename.c_str(), __FILE__, __func__, __LINE__); return; } @@ -146,19 +154,26 @@ void ClanPkLog::save(const std::string &abbrev) { return; } + std::ostringstream out; for (std::list::const_iterator i = pk_log.begin(); i != pk_log.end(); ++i) { - file << *i; + out << *i; } + // Граница записи: на диск уходит кодировка мира (сейчас KOI8-R), зеркально + // чтению -- иначе первое же сохранение переводит файл в UTF-8, и откат на + // прежнюю сборку становится невозможен (issue #3681). + const std::string on_disk = native_text::to_disk(out.str()); + file.write(on_disk.data(), static_cast(on_disk.size())); - file.close(); need_save = false; } void ClanPkLog::load(const std::string &abbrev) { std::string filename = LIB_CLANS + abbrev + "/" + abbrev + ".war"; - std::ifstream file(filename.c_str(), std::ios::binary); - if (!file.is_open()) { + // Граница чтения: файл лежит на диске в кодировке мира, поднимаем его целиком -- + // дальше разбор идёт по нативному тексту и не меняется (issue #3681). + std::istringstream file(native_text::read_data_file(filename.c_str())); + if (file.str().empty()) { log("Error open file: %s! (%s %s %d)", filename.c_str(), __FILE__, __func__, __LINE__); return; } @@ -169,7 +184,6 @@ void ClanPkLog::load(const std::string &abbrev) { buffer += "\r\n"; pk_log.push_back(buffer); } - file.close(); } void ClanPkLog::check(CharData *ch, CharData *victim) { @@ -223,8 +237,10 @@ void ClanExpHistory::add_exp(long exp) { void ClanExpHistory::load(const std::string &abbrev) { std::string filename = LIB_CLANS + abbrev + "/" + abbrev + "-history.exp"; - std::ifstream file(filename.c_str(), std::ios::binary); - if (!file.is_open()) { + // Граница чтения: файл лежит на диске в кодировке мира, поднимаем его целиком -- + // дальше разбор идёт по нативному тексту и не меняется (issue #3681). + std::istringstream file(native_text::read_data_file(filename.c_str())); + if (file.str().empty()) { log("Error open file: %s! (%s %s %d)", filename.c_str(), __FILE__, __func__, __LINE__); return; } @@ -234,7 +250,6 @@ void ClanExpHistory::load(const std::string &abbrev) { while (file >> buffer >> exp) { list_[buffer] = exp; } - file.close(); } void ClanExpHistory::save(const std::string &abbrev) const { @@ -252,10 +267,15 @@ void ClanExpHistory::save(const std::string &abbrev) const { return; } + std::ostringstream out; for (HistoryExpListType::const_iterator i = list_.begin(); i != list_.end(); ++i) { - file << i->first << " " << i->second << "\n"; + out << i->first << " " << i->second << "\n"; } - file.close(); + // Граница записи: на диск уходит кодировка мира (сейчас KOI8-R), зеркально + // чтению -- иначе первое же сохранение переводит файл в UTF-8, и откат на + // прежнюю сборку становится невозможен (issue #3681). + const std::string on_disk = native_text::to_disk(out.str()); + file.write(on_disk.data(), static_cast(on_disk.size())); } /** @@ -376,20 +396,27 @@ void ClanChestLog::save(const std::string &abbrev) { return; } + std::ostringstream out; for (std::list::const_iterator i = chest_log_.begin(), iend = chest_log_.end(); i != iend; ++i) { - file << *i; + out << *i; } + // Граница записи: на диск уходит кодировка мира (сейчас KOI8-R), зеркально + // чтению -- иначе первое же сохранение переводит файл в UTF-8, и откат на + // прежнюю сборку становится невозможен (issue #3681). + const std::string on_disk = native_text::to_disk(out.str()); + file.write(on_disk.data(), static_cast(on_disk.size())); - file.close(); need_save_ = false; } void ClanChestLog::load(const std::string &abbrev) { std::string filename = LIB_CLANS + abbrev + "/" + abbrev + ".log"; - std::ifstream file(filename.c_str(), std::ios::binary); - if (!file.is_open()) { + // Граница чтения: файл лежит на диске в кодировке мира, поднимаем его целиком -- + // дальше разбор идёт по нативному тексту и не меняется (issue #3681). + std::istringstream file(native_text::read_data_file(filename.c_str())); + if (file.str().empty()) { return; } @@ -399,7 +426,6 @@ void ClanChestLog::load(const std::string &abbrev) { buffer += "\r\n"; chest_log_.push_back(buffer); } - file.close(); } //////////////////////////////////////////////////////////////////////////////// diff --git a/src/gameplay/clans/ingr_chest_saver.cpp b/src/gameplay/clans/ingr_chest_saver.cpp index fce8be52ea..d7fd6eb82a 100644 --- a/src/gameplay/clans/ingr_chest_saver.cpp +++ b/src/gameplay/clans/ingr_chest_saver.cpp @@ -1,5 +1,6 @@ // Part of Bylins http://www.mud.ru +#include "utils/native_text.h" #include "ingr_chest_saver.h" #include "house.h" @@ -57,7 +58,11 @@ bool save_one_chest(ObjData *chest, const std::string &filename) { // реально заполненную часть по tellp(). const auto written = out.tellp(); const auto contents = out.str(); - file.write(contents.data(), static_cast(written)); + // Граница записи: на диск уходит кодировка мира (сейчас KOI8-R), зеркально + // чтению -- иначе первое же сохранение переводит файл в UTF-8, и откат на + // прежнюю сборку становится невозможен (issue #3681). + const std::string on_disk = native_text::to_disk(contents.substr(0, static_cast(written))); + file.write(on_disk.data(), static_cast(on_disk.size())); file.close(); return true; } diff --git a/src/gameplay/communication/boards/boards_changelog_loaders.cpp b/src/gameplay/communication/boards/boards_changelog_loaders.cpp index f8f363a5b0..fccd268462 100644 --- a/src/gameplay/communication/boards/boards_changelog_loaders.cpp +++ b/src/gameplay/communication/boards/boards_changelog_loaders.cpp @@ -1,4 +1,5 @@ #include "boards_changelog_loaders.h" +#include "utils/native_text.h" #include "utils/logger.h" #include "boards_constants.h" @@ -42,7 +43,7 @@ void ChangeLogLoaderImplementation::add_message(const std::string &author, std::string subj(message->text.begin(), std::find(message->text.begin(), message->text.end(), '\n')); /* if (subj.size() > 40) { - subj = subj.substr(0, 40); + subj = subj.substr(0, native_text::char_offset(subj, 40)); } */ utils::Trim(subj); message->subject = subj; @@ -203,6 +204,9 @@ bool GitChangeLogLoader::GitCommitReader::next_line(std::string &buffer, std::is if (!std::getline(is, buffer)) { return false; } + // Changelog лежит на диске в KOI8-R и читается потоком; границу кодировки проходим здесь + // (issue #3681). Под KOI8-R это тождество. + buffer = native_text::from_disk_line(buffer.c_str()); ++line; return true; @@ -221,6 +225,7 @@ bool GitChangeLogLoader::load(std::istream &is) { int line = 0; std::string buffer; while (std::getline(is, buffer)) { + buffer = native_text::from_disk_line(buffer.c_str()); ++line; switch (parser_state) { case REVISION: diff --git a/src/gameplay/communication/boards/boards_types.cpp b/src/gameplay/communication/boards/boards_types.cpp index ff411c3500..98550c1670 100644 --- a/src/gameplay/communication/boards/boards_types.cpp +++ b/src/gameplay/communication/boards/boards_types.cpp @@ -1,4 +1,5 @@ #include "boards_types.h" +#include "utils/native_text.h" #include "boards_constants.h" #include "utils/logger.h" @@ -58,6 +59,13 @@ void Board::Load() { ReadEndString(file); std::getline(file, message->text, '~'); + // Доски лежат на диске в KOI8-R и читаются потоком, минуя FBFILE, поэтому границу + // кодировки проходим здесь -- по текстовым полям (issue #3681). Под KOI8-R это + // тождество. + message->author = native_text::from_disk_line(message->author.c_str()); + message->subject = native_text::from_disk_line(message->subject.c_str()); + message->text = native_text::from_disk_line(message->text.c_str()); + // не помешает глянуть че мы там залоадили if (message->author.empty() || !message->unique @@ -135,20 +143,26 @@ void Board::Save() { log("Error open file: %s! (%s %s %d)", file_.c_str(), __FILE__, __func__, __LINE__); return; } - file << "Type: " << type_ << " " - << "Clan: " << clan_rent_ << " " - << "PersUID: " << pers_unique_ << " " - << "PersName: " << (pers_name_.empty() ? "none" : pers_name_) << "\n"; + // Собираем всё в строку и приводим к кодировке диска одним куском: граница записи -- зеркало + // границы чтения (доски читаются через from_disk_line), иначе первое же сохранение доски + // переводит её файл в UTF-8, а мир вокруг остаётся в KOI8-R (issue #3681). + std::ostringstream out; + out << "Type: " << type_ << " " + << "Clan: " << clan_rent_ << " " + << "PersUID: " << pers_unique_ << " " + << "PersName: " << (pers_name_.empty() ? "none" : pers_name_) << "\n"; for (MessageListType::const_reverse_iterator message = messages.rbegin(); message != messages.rend(); ++message) { - file << "Message: " << (*message)->num << "\n" - << (*message)->author << " " - << (*message)->unique << " " - << (*message)->level << " " - << (*message)->date << " " - << (*message)->rank << "\n" - << (*message)->subject << "~\n" - << (*message)->text << "~\n"; + out << "Message: " << (*message)->num << "\n" + << (*message)->author << " " + << (*message)->unique << " " + << (*message)->level << " " + << (*message)->date << " " + << (*message)->rank << "\n" + << (*message)->subject << "~\n" + << (*message)->text << "~\n"; } + const std::string on_disk = native_text::to_disk(out.str()); + file.write(on_disk.data(), static_cast(on_disk.size())); file.close(); } diff --git a/src/gameplay/communication/mail.cpp b/src/gameplay/communication/mail.cpp index 707e03d6bd..38555ca4ce 100644 --- a/src/gameplay/communication/mail.cpp +++ b/src/gameplay/communication/mail.cpp @@ -8,7 +8,9 @@ * CircleMUD is based on DikuMUD, Copyright (C) 1990, 1991. * ************************************************************************ */ +#include #include "mail.h" +#include "utils/native_text.h" #include "administration/privilege.h" #include "engine/db/global_objects.h" #include "gameplay/economics/currencies.h" @@ -588,13 +590,20 @@ void save() { msg_n.append_attribute("t") = i->second.text.c_str(); } - doc.save_file(MAIL_XML_FILE); + // Граница записи: XML уходит на диск в кодировке мира, а не в нативной + // (issue #3681). + std::ostringstream xml; + doc.save(xml, "\t", pugi::format_default, pugi::encoding_utf8); + native_text::write_file(MAIL_XML_FILE, xml.str()); need_save = false; } void load() { pugi::xml_document doc; - pugi::xml_parse_result result = doc.load_file(MAIL_XML_FILE); + // Файл лежит на диске в KOI8-R; читаем через границу кодировки, а разбираем уже + // буфер в нативной кодировке движка (issue #3681). Под KOI8-R это тождество. + const std::string xml_mail = native_text::read_data_file(MAIL_XML_FILE); + pugi::xml_parse_result result = doc.load_buffer(xml_mail.data(), xml_mail.size()); if (!result) { snprintf(buf, kMaxStringLength, "...%s", result.description()); mudlog(buf, CMP, kLvlImmortal, SYSLOG, true); diff --git a/src/gameplay/communication/parcel.cpp b/src/gameplay/communication/parcel.cpp index 5e288d57aa..8293d922a2 100644 --- a/src/gameplay/communication/parcel.cpp +++ b/src/gameplay/communication/parcel.cpp @@ -2,7 +2,9 @@ // Copyright (c) 2008 Krodo // Part of Bylins http://www.mud.ru +#include #include "parcel.h" +#include "utils/native_text.h" #include "engine/db/player_index.h" #include "administration/privilege.h" #include "gameplay/economics/currencies.h" @@ -289,7 +291,7 @@ void send(CharData *ch, CharData *mailman, long vict_uid, char *arg) { if (is_number(tmp_arg)) { int amount = atoi(tmp_arg); - if (!strn_cmp("coin", tmp_arg2, 4) || !strn_cmp("кун", tmp_arg2, 5) || !str_cmp("денег", tmp_arg2)) { + if (utils::IsAbbr("coin", tmp_arg2) || utils::IsAbbr("кун", tmp_arg2) || !str_cmp("денег", tmp_arg2)) { act("$n сказал$g вам : 'Для перевода денег воспользуйтесь услугами банка.'", false, mailman, @@ -456,8 +458,8 @@ void fill_ex_desc(CharData *ch, ObjData *obj, std::string sender) { "неуклюжего взлома - царская служба безопасности бдит...\r\n" "На табличке сбоку видны надписи:\r\n\r\n"; out << std::setw(size + 16) << std::setfill('-') << " " << std::setfill(' ') << "\r\n"; - out << "| Отправитель: " << std::setw(size) << sender - << " |\r\n| Получатель: " << std::setw(size) << GET_NAME(ch) << " |\r\n"; + out << "| Отправитель: " << fmt::format("{:>{}}", sender, size) + << " |\r\n| Получатель: " << fmt::format("{:>{}}", GET_NAME(ch), size) << " |\r\n"; out << std::setw(size + 16) << std::setfill('-') << " " << std::setfill(' ') << "\r\n"; obj->set_ex_description("посылка бандероль пакет ящик parcel box case chest", out.str().c_str()); @@ -668,18 +670,22 @@ void load() { int fsize = ftell(fl); char *data, *readdata; - CREATE(readdata, fsize + 1); + std::vector raw(fsize + 1, '\0'); fseek(fl, 0L, SEEK_SET); - if (!fread(readdata, fsize, 1, fl) || ferror(fl)) { + if (!fread(raw.data(), fsize, 1, fl) || ferror(fl)) { fclose(fl); log("SYSERR: Memory error or cann't read parcel database file."); - free(readdata); return; }; fclose(fl); + // Граница чтения: база лежит на диске в KOI8-R, движок держит текст в нативной кодировке + // (issue #3681). + const std::string native = native_text::from_disk_text(std::string(raw.data(), fsize)); + CREATE(readdata, native.size() + 1); + memcpy(readdata, native.c_str(), native.size() + 1); + data = readdata; - *(data + fsize) = '\0'; for (fsize = 0; *data && *data != '$'; fsize++) { int error; @@ -724,7 +730,11 @@ void save() { log("SYSERR: error opening file: %s! (%s %s %d)", FILE_NAME, __FILE__, __func__, __LINE__); return; } - file << out.rdbuf(); + // Граница записи: на диск уходит кодировка мира (сейчас KOI8-R), зеркально + // чтению -- иначе первое же сохранение переводит файл в UTF-8, и откат на + // прежнюю сборку становится невозможен (issue #3681). + const std::string on_disk = native_text::to_disk(out.str()); + file.write(on_disk.data(), static_cast(on_disk.size())); file.close(); return; @@ -828,13 +838,13 @@ bool print_imm_where_obj(CharData *ch, const ObjData *arg, int num) { std::string sender = GetNameByUnique(it2->first); found = true; - SendMsgToChar(ch, "%2d. [%6d] %-25s - наход%sся на почте (отправитель: %s, получатель: %s).\r\n", + SendMsgToChar(fmt::format("{:2}. [{:6}] {:<25} - наход{}ся на почте (отправитель: {}, получатель: {}).\r\n", num++, GET_OBJ_VNUM(it3->obj_.get()), - it3->obj_->get_short_description().c_str(), + it3->obj_->get_short_description(), grammar::ObjPluralVerbEnding((it3->obj_)->get_sex()), - sender.c_str(), - target.c_str()); + sender, + target), ch); } } } @@ -852,8 +862,8 @@ std::string FindParcelObj(const ObjData *obj) { std::string target = GetNameByUnique(it->first); std::string sender = GetNameByUnique(it2->first); - target[0] = UPPER(target[0]); - sender[0] = UPPER(sender[0]); + native_text::capitalize_first(target); + native_text::capitalize_first(sender); str = fmt::format("наход{}ся на почте (отправитель: {}, получатель: {}).\r\n", grammar::ObjPluralVerbEnding((it3->obj_)->get_sex()), sender.c_str(), diff --git a/src/gameplay/communication/social.cpp b/src/gameplay/communication/social.cpp index d2ffc8c872..8cfa42e418 100644 --- a/src/gameplay/communication/social.cpp +++ b/src/gameplay/communication/social.cpp @@ -235,9 +235,8 @@ int find_action(char *cmd) { if (!cmd || !*cmd) { return -1; } - const std::size_t len = std::strlen(cmd); for (const auto &entry : g_social_index) { - if (strn_cmp(cmd, entry.first.c_str(), len) == 0) { + if (utils::IsAbbr(cmd, entry.first.c_str())) { return entry.second; } } diff --git a/src/gameplay/core/game_limits.cpp b/src/gameplay/core/game_limits.cpp index d766f4b826..43c50ab7da 100644 --- a/src/gameplay/core/game_limits.cpp +++ b/src/gameplay/core/game_limits.cpp @@ -13,6 +13,7 @@ ************************************************************************ */ #include "gameplay/core/game_limits.h" +#include "utils/native_text.h" #include "gameplay/core/experience.h" #include "gameplay/affects/affect_data.h" // issue.mob-flag-affect-materialization: restore re-materialize #include "administration/privilege.h" @@ -1172,7 +1173,7 @@ void exchange_point_update() { if (GET_EXCHANGE_ITEM(exch_item)->get_timer() == 0) { std::string cap = GET_EXCHANGE_ITEM(exch_item)->get_PName(grammar::ECase::kNom); - cap[0] = UPPER(cap[0]); + native_text::capitalize_first(cap); sprintf(buf, "Exchange: - %s рассыпал%s от длительного использования.\r\n", cap.c_str(), grammar::ObjSexEnding((GET_EXCHANGE_ITEM(exch_item))->get_sex(), 2)); log("%s", buf); @@ -1257,7 +1258,7 @@ void charmee_obj_decay_tell(CharData *charmee, ObjData *obj, ECharmeeObjPos obj_ короче, рефакторинг приветствуется, если кто-нибудь придумает лучше. */ std::string cap = obj->get_PName(grammar::ECase::kNom); - cap[0] = UPPER(cap[0]); + native_text::capitalize_first(cap); snprintf(local_buf, kMaxStringLength, "%s сказал%s вам : '%s%s рассыпал%s %s...'", GET_NAME(charmee), grammar::SexEnding((charmee)->get_sex(), 1), diff --git a/src/gameplay/core/genchar.cpp b/src/gameplay/core/genchar.cpp index fb1a6c327f..777d24e887 100644 --- a/src/gameplay/core/genchar.cpp +++ b/src/gameplay/core/genchar.cpp @@ -13,6 +13,7 @@ ************************************************************************ */ #include "genchar.h" +#include "utils/russian_keys.h" #include "engine/core/conf.h" #include "engine/core/sysdep.h" @@ -20,6 +21,7 @@ #include "engine/core/comm.h" #include "utils/logger.h" #include "utils/utils.h" +#include "utils/native_text.h" #include "gameplay/magic/spells.h" #include "engine/entities/char_data.h" #include "engine/entities/char_player.h" @@ -94,48 +96,48 @@ void genchar_disp_menu(CharData *ch) { int genchar_parse(CharData *ch, char *arg) { const auto &ch_class = MUD::Class(ch->GetClass()); - switch (*arg) { - case 'А': - case 'а': ch->set_str(std::max(ch->GetInbornStr() - 1, ch_class.GetBaseStatGenMin(EBaseStat::kStr))); + switch (native_text::first_char_code(arg)) { + case rus::kAUpper: + case rus::kA: ch->set_str(std::max(ch->GetInbornStr() - 1, ch_class.GetBaseStatGenMin(EBaseStat::kStr))); break; - case 'Б': - case 'б': ch->set_dex(std::max(ch->GetInbornDex() - 1, ch_class.GetBaseStatGenMin(EBaseStat::kDex))); + case rus::kBeUpper: + case rus::kBe: ch->set_dex(std::max(ch->GetInbornDex() - 1, ch_class.GetBaseStatGenMin(EBaseStat::kDex))); break; - case 'Г': - case 'г': ch->set_int(std::max(ch->GetInbornInt() - 1, ch_class.GetBaseStatGenMin(EBaseStat::kInt))); + case rus::kGeUpper: + case rus::kGe: ch->set_int(std::max(ch->GetInbornInt() - 1, ch_class.GetBaseStatGenMin(EBaseStat::kInt))); break; - case 'Д': - case 'д': ch->set_wis(std::max(ch->GetInbornWis() - 1, ch_class.GetBaseStatGenMin(EBaseStat::kWis))); + case rus::kDeUpper: + case rus::kDe: ch->set_wis(std::max(ch->GetInbornWis() - 1, ch_class.GetBaseStatGenMin(EBaseStat::kWis))); break; - case 'Е': - case 'е': ch->set_con(std::max(ch->GetInbornCon() - 1, ch_class.GetBaseStatGenMin(EBaseStat::kCon))); + case rus::kIeUpper: + case rus::kIe: ch->set_con(std::max(ch->GetInbornCon() - 1, ch_class.GetBaseStatGenMin(EBaseStat::kCon))); break; - case 'Ж': - case 'ж': ch->set_cha(std::max(ch->GetInbornCha() - 1, ch_class.GetBaseStatGenMin(EBaseStat::kCha))); + case rus::kZheUpper: + case rus::kZhe: ch->set_cha(std::max(ch->GetInbornCha() - 1, ch_class.GetBaseStatGenMin(EBaseStat::kCha))); break; - case 'З': - case 'з': ch->set_str(std::min(ch->GetInbornStr() + 1, ch_class.GetBaseStatGenMax(EBaseStat::kStr))); + case rus::kZeUpper: + case rus::kZe: ch->set_str(std::min(ch->GetInbornStr() + 1, ch_class.GetBaseStatGenMax(EBaseStat::kStr))); break; - case 'И': - case 'и': ch->set_dex(std::min(ch->GetInbornDex() + 1, ch_class.GetBaseStatGenMax(EBaseStat::kDex))); + case rus::kIUpper: + case rus::kI: ch->set_dex(std::min(ch->GetInbornDex() + 1, ch_class.GetBaseStatGenMax(EBaseStat::kDex))); break; - case 'К': - case 'к': ch->set_int(std::min(ch->GetInbornInt() + 1, ch_class.GetBaseStatGenMax(EBaseStat::kInt))); + case rus::kKaUpper: + case rus::kKa: ch->set_int(std::min(ch->GetInbornInt() + 1, ch_class.GetBaseStatGenMax(EBaseStat::kInt))); break; - case 'Л': - case 'л': ch->set_wis(std::min(ch->GetInbornWis() + 1, ch_class.GetBaseStatGenMax(EBaseStat::kWis))); + case rus::kElUpper: + case rus::kEl: ch->set_wis(std::min(ch->GetInbornWis() + 1, ch_class.GetBaseStatGenMax(EBaseStat::kWis))); break; - case 'М': - case 'м': ch->set_con(std::min(ch->GetInbornCon() + 1, ch_class.GetBaseStatGenMax(EBaseStat::kCon))); + case rus::kEmUpper: + case rus::kEm: ch->set_con(std::min(ch->GetInbornCon() + 1, ch_class.GetBaseStatGenMax(EBaseStat::kCon))); break; - case 'Н': - case 'н': ch->set_cha(std::min(ch->GetInbornCha() + 1, ch_class.GetBaseStatGenMax(EBaseStat::kCha))); + case rus::kEnUpper: + case rus::kEn: ch->set_cha(std::min(ch->GetInbornCha() + 1, ch_class.GetBaseStatGenMax(EBaseStat::kCha))); break; - case 'П': - case 'п': SendMsgToChar(genchar_help, ch); + case rus::kPeUpper: + case rus::kPe: SendMsgToChar(genchar_help, ch); break; - case 'В': - case 'в': + case rus::kVeUpper: + case rus::kVe: if (CalcBasseStatsSum(ch) != kBaseStatsSum) break; // по случаю успешной генерации сохраняем стартовые статы @@ -146,8 +148,8 @@ int genchar_parse(CharData *ch, char *arg) { ch->set_start_stat(G_CON, ch->GetInbornCon()); ch->set_start_stat(G_CHA, ch->GetInbornCha()); return kGencharExit; - case 'О': - case 'о': { + case rus::kOUpper: + case rus::kO: { const auto &tmp_class = MUD::Class(ch->GetClass()); ch->set_str(tmp_class.GetBaseStatGenAuto(EBaseStat::kStr)); ch->set_dex(tmp_class.GetBaseStatGenAuto(EBaseStat::kDex)); @@ -249,10 +251,18 @@ void SetStartAbils(CharData *ch) { // 5 - предложный (о ком? о чем?) // result - результат void GetCase(std::string name, const EGender sex, int caseNum, char *data) { - size_t len = name.size(); std::string result = data; - if (strchr("цкнгшщзхфвпрлджчсмтб", name[len - 1]) != nullptr + // The declension is chosen by the last letter of the name (and sometimes the one before it). + // Those are *characters*, not bytes (issue #3681): under KOI8-R `stem`/`last`/`prev` are the + // same single bytes the old name[len - 1] / name[len - 2] / substr(0, len - 1) produced, + // under UTF-8 they are whole letters. + const size_t last_off = native_text::last_char_offset(name); + const std::string stem = name.substr(0, last_off); + const std::string last = name.substr(last_off); + const std::string prev = stem.substr(native_text::last_char_offset(stem)); + + if (native_text::list_contains_char("цкнгшщзхфвпрлджчсмтб", last) && sex == EGender::kMale) { result = name; if (caseNum == 1) @@ -265,8 +275,8 @@ void GetCase(std::string name, const EGender sex, int caseNum, char *data) { result += "ом"; // Иваном, Ретичем else if (caseNum == 5) result += "е"; // Иване - } else if (name[len - 1] == 'я') { - result = name.substr(0, len - 1); + } else if (last == "я") { + result = stem; if (caseNum == 1) result += "и"; // Ани, Вани else if (caseNum == 2) @@ -279,9 +289,9 @@ void GetCase(std::string name, const EGender sex, int caseNum, char *data) { result += "е"; // Ане, Ване else result += "я"; // Аня, Ваня - } else if (name[len - 1] == 'й' + } else if (last == "й" && sex == EGender::kMale) { - result = name.substr(0, len - 1); + result = stem; if (caseNum == 1) result += "я"; // Дрегвия else if (caseNum == 2) @@ -294,10 +304,10 @@ void GetCase(std::string name, const EGender sex, int caseNum, char *data) { result += "и"; // Дрегвии else result += "й"; // Дрегвий - } else if (name[len - 1] == 'а') { - result = name.substr(0, len - 1); + } else if (last == "а") { + result = stem; if (caseNum == 1) { - if (strchr("шщжч", name[len - 2]) != nullptr) + if (native_text::list_contains_char("шщжч", prev)) result += "и"; // Маши, Паши else result += "ы"; // Анны @@ -306,7 +316,7 @@ void GetCase(std::string name, const EGender sex, int caseNum, char *data) { else if (caseNum == 3) result += "у"; // Пашу, Анну else if (caseNum == 4) { - if (strchr("шщч", name[len - 2]) != nullptr) + if (native_text::list_contains_char("шщч", prev)) result += "ей"; // Машей, Пашей else result += "ой"; // Анной, Ханжой diff --git a/src/gameplay/crafting/craft.cpp b/src/gameplay/crafting/craft.cpp index 80baeccf42..72113f64db 100644 --- a/src/gameplay/crafting/craft.cpp +++ b/src/gameplay/crafting/craft.cpp @@ -4,7 +4,9 @@ * \author Anton Gorev */ +#include #include "craft.h" +#include "utils/native_text.h" #include "gameplay/mechanics/magic_item.h" #include "engine/db/obj_prototypes.h" @@ -1074,7 +1076,10 @@ bool CCraftModel::load() { Logger::CPrefix prefix(logger, BODY_PREFIX); pugi::xml_document doc; - const auto result = doc.load_file(FILE_NAME.c_str()); + // Файл лежит на диске в KOI8-R; читаем через границу кодировки, а разбираем уже + // буфер в нативной кодировке движка (issue #3681). Под KOI8-R это тождество. + const std::string xml_craft = native_text::read_data_file(FILE_NAME.c_str()); + const auto result = doc.load_buffer(xml_craft.data(), xml_craft.size()); if (!result) { logger("Craft load error: '%s' at offset %zu\n", @@ -1545,7 +1550,11 @@ bool CCraftModel::export_object(const ObjVnum vnum, const char *filename) { decl.append_attribute("version") = "1.0"; decl.append_attribute("encoding") = "koi8-r"; - return document.save_file(filename); + // Граница записи: XML уходит на диск в кодировке мира, а не в нативной. Объявление выше + // так и заявляет koi8-r, значит и байты должны быть koi8-r (issue #3681). + std::ostringstream xml; + document.save(xml, "\t", pugi::format_default, pugi::encoding_utf8); + return native_text::write_file(filename, xml.str()); } const std::string CObject::KIND = "simple object"; diff --git a/src/gameplay/crafting/im.cpp b/src/gameplay/crafting/im.cpp index 5d555bdcdc..54ab086dbd 100644 --- a/src/gameplay/crafting/im.cpp +++ b/src/gameplay/crafting/im.cpp @@ -11,8 +11,10 @@ // Реализация ингредиентной магии #include "im.h" +#include #include "utils/parser_wrapper.h" #include "utils/utils_parse.h" +#include "utils/native_text.h" #include #include #include @@ -66,7 +68,7 @@ int im_get_type_by_name(char *name, int mode) { for (i = 0; i <= top_imtypes; ++i) { if (mode == 0 && imtypes[i].proto_vnum == -1) continue; - if (!strn_cmp(name, imtypes[i].name, strlen(imtypes[i].name))) + if (utils::IsAbbr(imtypes[i].name, name)) return i; } return -1; @@ -246,7 +248,17 @@ const char *replace_alias(const char *ptr, im_memb *sample, int rnum, const char if (*ptr == VAR_CHAR) { int k; ++ptr; - for (k = 0; (*ptr) && a_isalnum(*ptr); aname[k++] = *ptr++); + // One whole character per step (issue #3681), with a bounds check: the + // previous loop had none, and multibyte text fills aname[] twice as fast. + for (k = 0; *ptr && native_text::is_alnum_char(ptr);) { + const size_t bytes = native_text::char_bytes(ptr); + if (static_cast(k) + bytes >= sizeof(aname)) { + break; + } + for (size_t i = 0; i < bytes; ++i) { + aname[k++] = *ptr++; + } + } aname[k] = 0; al = get_im_alias(sample, aname); strcpy(dst, al ? al : aname); @@ -942,14 +954,14 @@ void list_recipes(CharData *ch, bool all_recipes) { rs = im_get_char_rskill(ch, sortpos); const bool unavailable = req->level > GetRealLevel(ch) || req->remort > remort::GetRealRemort(ch); if (!ch->IsFlagged(EPrf::kBlindMode)) { - sprintf(buf, " %s%-30s%s %2d (%2d)%s\r\n", + strcpy(buf, fmt::format(" {}{:<30}{} {:2} ({:2}){}\r\n", unavailable ? kColorRed : rs ? kColorGrn : kColorNrm, imrecipes[sortpos].name, kColorCyn, - req->level, req->remort, kColorNrm); + req->level, req->remort, kColorNrm).c_str()); } else { - sprintf(buf, " %s %-30s %2d (%2d)\r\n", + strcpy(buf, fmt::format(" {} {:<30} {:2} ({:2})\r\n", unavailable ? "[Н]" : rs ? "[И]" : "[Д]", imrecipes[sortpos].name, - req->level, req->remort); + req->level, req->remort).c_str()); } strcat(buf1, buf); ++i; @@ -971,7 +983,7 @@ void list_recipes(CharData *ch, bool all_recipes) { } if (rs->perc <= 0) continue; - sprintf(buf, "%-30s %s%s\r\n", imrecipes[rs->rid].name, how_good(rs->perc, kMaxRecipeLevel), kColorBoldBlk); + strcpy(buf, fmt::format("{:<30} {}{}\r\n", imrecipes[rs->rid].name, how_good(rs->perc, kMaxRecipeLevel), kColorBoldBlk).c_str()); strcat(buf2, buf); ++i; } @@ -1038,8 +1050,11 @@ void do_rset(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { } // Locate the last quote and lowercase the magic words (if any) - for (qend = 1; argument[qend] && argument[qend] != '\''; qend++) - argument[qend] = LOWER(argument[qend]); + // Шагаем по символу: под UTF-8 русская буква занимает два байта (issue #3681). + for (qend = 1; argument[qend] && argument[qend] != '\''; + qend += static_cast(native_text::char_bytes(argument + qend))) { + native_text::copy_lower_char(argument + qend, argument + qend); + } if (argument[qend] != '\'') { SendMsgToChar("Рецепт должен быть заключен в символы : ''\r\n", ch); @@ -1205,8 +1220,11 @@ void do_cook(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { SendMsgToChar("Рецепт надо заключить в символы : ' * или !\r\n", ch); return; } - for (qend = 1; argument[qend] && !IS_RECIPE_DELIM(argument[qend]); qend++) - argument[qend] = LOWER(argument[qend]); + // Шагаем по символу: под UTF-8 русская буква занимает два байта (issue #3681). + for (qend = 1; argument[qend] && !IS_RECIPE_DELIM(argument[qend]); + qend += static_cast(native_text::char_bytes(argument + qend))) { + native_text::copy_lower_char(argument + qend, argument + qend); + } if (!IS_RECIPE_DELIM(argument[qend])) { SendMsgToChar("Рецепт должен быть заключен в символы : ' * или !\r\n", ch); return; @@ -1525,8 +1543,10 @@ void compose_recipe(CharData *ch, char *argument, int/* subcmd*/) { return; } - for (qend = 1; argument[qend] && !IS_RECIPE_DELIM(argument[qend]); qend++) { - argument[qend] = LOWER(argument[qend]); + // Шагаем по символу: под UTF-8 русская буква занимает два байта (issue #3681). + for (qend = 1; argument[qend] && !IS_RECIPE_DELIM(argument[qend]); + qend += static_cast(native_text::char_bytes(argument + qend))) { + native_text::copy_lower_char(argument + qend, argument + qend); } if (!IS_RECIPE_DELIM(argument[qend])) { @@ -1583,8 +1603,11 @@ void forget_recipe(CharData *ch, char *argument, int/* subcmd*/) { SendMsgToChar("Рецепт надо заключить в символы : ' * или !\r\n", ch); return; } - for (qend = 1; argument[qend] && !IS_RECIPE_DELIM(argument[qend]); qend++) - argument[qend] = LOWER(argument[qend]); + // Шагаем по символу: под UTF-8 русская буква занимает два байта (issue #3681). + for (qend = 1; argument[qend] && !IS_RECIPE_DELIM(argument[qend]); + qend += static_cast(native_text::char_bytes(argument + qend))) { + native_text::copy_lower_char(argument + qend, argument + qend); + } if (!IS_RECIPE_DELIM(argument[qend])) { SendMsgToChar("Рецепт должен быть заключен в символы : ' * или !\r\n", ch); return; @@ -1593,9 +1616,8 @@ void forget_recipe(CharData *ch, char *argument, int/* subcmd*/) { argument += qend + 1; name[qend - 1] = '\0'; - size_t i = strlen(name); for (rcpt = top_imrecipes; rcpt >= 0; --rcpt) { - if (!strn_cmp(name, imrecipes[rcpt].name, i)) { + if (utils::IsAbbr(name, imrecipes[rcpt].name)) { break; } } diff --git a/src/gameplay/crafting/item_creation.cpp b/src/gameplay/crafting/item_creation.cpp index 05a066ece5..6c8bfe51c3 100644 --- a/src/gameplay/crafting/item_creation.cpp +++ b/src/gameplay/crafting/item_creation.cpp @@ -6,7 +6,9 @@ * $Date$ * * $Revision$ * ************************************************************************ */ +#include "utils/native_text.h" #include "item_creation.h" +#include #include "utils/utils_parse.h" #include "utils/parser_wrapper.h" #include "administration/privilege.h" @@ -324,7 +326,7 @@ void do_list_make(CharData *ch, char * /*argument*/, int/* cmd*/, int/* subcmd*/ trec = make_recepts[i]; auto obj = GetObjectPrototype(trec->obj_proto); if (obj) { - obj_name = utils::RemoveColors(obj->get_PName(grammar::ECase::kNom).substr(0, 39)); + obj_name = utils::RemoveColors(obj->get_PName(grammar::ECase::kNom).substr(0, native_text::char_offset(obj->get_PName(grammar::ECase::kNom), 39))); } while (make_skills[j].num != ESkill::kUndefined) { if (make_skills[j].num == trec->skill) { @@ -333,18 +335,18 @@ void do_list_make(CharData *ch, char * /*argument*/, int/* cmd*/, int/* subcmd*/ } j++; } - sprintf(tmpbuf, "%3zd %-1s %-6s %-40s(%5d) :", - i + 1, (trec->locked ? "*" : " "), skill_name.c_str(), obj_name.c_str(), trec->obj_proto); - tmpstr += string(tmpbuf); + tmpstr += fmt::format("{:3} {:<1} {:<6} {:<40}({:5}) :", + i + 1, (trec->locked ? "*" : " "), skill_name, + obj_name, trec->obj_proto); for (int j = 0; j < MAX_PARTS; j++) { if (trec->parts[j].proto != 0) { obj = GetObjectPrototype(trec->parts[j].proto); if (obj) { - obj_name = utils::RemoveColors(obj->get_PName(grammar::ECase::kNom).substr(0, 34)); + obj_name = utils::RemoveColors(obj->get_PName(grammar::ECase::kNom).substr(0, native_text::char_offset(obj->get_PName(grammar::ECase::kNom), 34))); } else { obj_name = "Нет"; } - sprintf(tmpbuf, " %-35s(%5d)", obj_name.c_str(), trec->parts[j].proto); + strcpy(tmpbuf, fmt::format(" {:<35}({:5})", obj_name, trec->parts[j].proto).c_str()); if (j > 0) { if (j % 2 == 0) { // разбиваем строчки если ингров больше 2; diff --git a/src/gameplay/economics/exchange.cpp b/src/gameplay/economics/exchange.cpp index 8b968dbac7..0826e6a4c0 100644 --- a/src/gameplay/economics/exchange.cpp +++ b/src/gameplay/economics/exchange.cpp @@ -8,7 +8,9 @@ * $Revision$ * ************************************************************************ */ +#include #include "exchange.h" +#include "utils/native_text.h" #include "administration/privilege.h" #include "engine/db/global_objects.h" #include "gameplay/economics/currencies.h" @@ -472,7 +474,7 @@ int exchange_information(CharData *ch, char *arg) { } auto seller_name = GetNameById(GET_EXCHANGE_ITEM_SELLERID(item)); snprintf(buf2, sizeof(buf2), "%s", seller_name.empty() ? "(сожран долгоносиком)" : seller_name.c_str()); - *buf2 = UPPER(*buf2); + native_text::capitalize_first(buf2); out += fmt::sprintf("Продавец %s\n", buf2); if (GET_EXCHANGE_ITEM_COMMENT(item)) { out += fmt::sprintf("Берестовая наклейка на лоте гласит: '%s'.\n", GET_EXCHANGE_ITEM_COMMENT(item)); @@ -668,7 +670,7 @@ int exchange_offers(const CharData *ch, const char *arg) { filter += GET_NAME(ch); } else { while (*arg1) { - arg1[0] = UPPER(arg1[0]); + native_text::capitalize_first(arg1); filter += arg1; filter += ' '; arg = one_argument(arg, arg1); @@ -702,7 +704,7 @@ bool exchange_setfilter(CharData *ch, char *argument) { if (!correct_filter_length(ch, argument)) { return false; } - if (!strncmp(argument, "нет", 3)) { + if (!strncmp(argument, "нет", strlen("нет"))) { if (EXCHANGE_FILTER(ch)) { free(EXCHANGE_FILTER(ch)); EXCHANGE_FILTER(ch) = nullptr; @@ -869,19 +871,24 @@ int LoadExchange() { fseek(fl, 0L, SEEK_END); fsize = ftell(fl); - CREATE(readdata, fsize + 1); + std::vector raw(fsize + 1, '\0'); fseek(fl, 0L, SEEK_SET); - auto actual_size = fread(readdata, 1, fsize, fl); + auto actual_size = fread(raw.data(), 1, fsize, fl); if (!actual_size || ferror(fl)) { fclose(fl); log("SYSERR: Memory error or cann't read exchange database file. (exchange.cpp)"); - free(readdata); return (0); }; fclose(fl); + // Граница чтения: база лежит на диске в KOI8-R, а движок работает с текстом в нативной + // кодировке. Без этого названия лотов оставались бы байтами чужой кодировки -- и на экране, + // и при обратной записи через to_disk, которая приняла бы их за UTF-8 (issue #3681). + const std::string native = native_text::from_disk_text(std::string(raw.data(), actual_size)); + CREATE(readdata, native.size() + 1); + memcpy(readdata, native.c_str(), native.size() + 1); + data = readdata; - *(data + actual_size) = '\0'; // Новая база или старая? get_buf_line(&data, buffer); @@ -907,7 +914,7 @@ int LoadExchange() { // Предмет разваливается от старости if (GET_EXCHANGE_ITEM(item)->get_timer() <= 0) { std::string cap = GET_EXCHANGE_ITEM(item)->get_PName(grammar::ECase::kNom); - cap[0] = UPPER(cap[0]); + native_text::capitalize_first(cap); log("Exchange: - %s рассыпал%s от длительного использования.\r\n", cap.c_str(), grammar::ObjSexEnding((GET_EXCHANGE_ITEM(item))->get_sex(), 2)); extract_exchange_item(item); @@ -955,22 +962,25 @@ int exchange_database_reload(bool loadbackup) { fseek(fl, 0L, SEEK_END); fsize = ftell(fl); - CREATE(readdata, fsize + 1); + std::vector raw(fsize + 1, '\0'); fseek(fl, 0L, SEEK_SET); - auto actual_size = fread(readdata, 1, fsize, fl); + auto actual_size = fread(raw.data(), 1, fsize, fl); if (!actual_size || ferror(fl)) { fclose(fl); if (loadbackup) log("SYSERR: Memory error or cann't read exchange database backup file. (exchange.cpp)"); else log("SYSERR: Memory error or cann't read exchange database file. (exchange.cpp)"); - free(readdata); return (0); }; fclose(fl); + // Граница чтения, как и выше (issue #3681). + const std::string native = native_text::from_disk_text(std::string(raw.data(), actual_size)); + CREATE(readdata, native.size() + 1); + memcpy(readdata, native.c_str(), native.size() + 1); + data = readdata; - *(data + actual_size) = '\0'; // Новая база или старая? get_buf_line(&data, buffer); @@ -1002,7 +1012,7 @@ int exchange_database_reload(bool loadbackup) { // Предмет разваливается от старости if (GET_EXCHANGE_ITEM(item)->get_timer() <= 0) { std::string cap = GET_EXCHANGE_ITEM(item)->get_PName(grammar::ECase::kNom); - cap[0] = UPPER(cap[0]); + native_text::capitalize_first(cap); log("Exchange: - %s рассыпал%s от длительного использования.\r\n", cap.c_str(), grammar::ObjSexEnding((GET_EXCHANGE_ITEM(item))->get_sex(), 2)); extract_exchange_item(item); @@ -1048,7 +1058,11 @@ void exchange_database_save(bool backup) { mudlog(buf, BRF, kLvlImmortal, SYSLOG, true); return; } - file << out.rdbuf(); + // Граница записи: на диск уходит кодировка мира (сейчас KOI8-R), зеркально + // чтению -- иначе первое же сохранение переводит файл в UTF-8, и откат на + // прежнюю сборку становится невозможен (issue #3681). + const std::string on_disk = native_text::to_disk(out.str()); + file.write(on_disk.data(), static_cast(on_disk.size())); file.close(); log("Exchange: done saving database."); diff --git a/src/gameplay/economics/shop_ext.cpp b/src/gameplay/economics/shop_ext.cpp index 32786501f4..206bb7cb6d 100644 --- a/src/gameplay/economics/shop_ext.cpp +++ b/src/gameplay/economics/shop_ext.cpp @@ -3,6 +3,7 @@ // Part of Bylins http://www.mud.ru #include "shop_ext.h" +#include "utils/native_text.h" #include "engine/olc/vedun/enum_registry.h" // vedun::RegisterEditorEnums (refresh ShopItemSetId) #include #include "gameplay/economics/currencies.h" @@ -148,7 +149,10 @@ void log_shop_load() { void load_item_desc() { pugi::xml_document doc; - pugi::xml_parse_result result = doc.load_file(LIB_CLANS"item_desc.xml"); + // Файл лежит на диске в KOI8-R; читаем через границу кодировки, а разбираем уже + // буфер в нативной кодировке движка (issue #3681). Под KOI8-R это тождество. + const std::string xml_shop_ext = native_text::read_data_file(LIB_CLANS"item_desc.xml"); + pugi::xml_parse_result result = doc.load_buffer(xml_shop_ext.data(), xml_shop_ext.size()); if (!result) { snprintf(buf, kMaxStringLength, "...%s", result.description()); mudlog(buf, CMP, kLvlImmortal, SYSLOG, true); diff --git a/src/gameplay/fight/fight_hit.cpp b/src/gameplay/fight/fight_hit.cpp index 1698bae9be..d90870a14c 100644 --- a/src/gameplay/fight/fight_hit.cpp +++ b/src/gameplay/fight/fight_hit.cpp @@ -883,8 +883,8 @@ void EmitMissEvent(CharData *ch, CharData *victim, const char *reason) { ev.name = "miss"; ev.ts_unix_ms = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); - ev.attrs["attacker_name"] = observability::EngineStringToUtf8(GET_NAME(ch) ? GET_NAME(ch) : ""); - ev.attrs["victim_name"] = observability::EngineStringToUtf8(GET_NAME(victim) ? GET_NAME(victim) : ""); + ev.attrs["attacker_name"] = GET_NAME(ch) ? GET_NAME(ch) : ""; + ev.attrs["victim_name"] = GET_NAME(victim) ? GET_NAME(victim) : ""; ev.attrs["reason"] = std::string(reason); observability::EmitToAllSinks(ev); } diff --git a/src/gameplay/fight/pk.cpp b/src/gameplay/fight/pk.cpp index d927493e2a..7b092d481d 100644 --- a/src/gameplay/fight/pk.cpp +++ b/src/gameplay/fight/pk.cpp @@ -12,6 +12,8 @@ ************************************************************************ */ #include "pk.h" +#include "utils/native_text.h" +#include #include "administration/privilege.h" #include "gameplay/mechanics/minions.h" #include "gameplay/mechanics/mount.h" @@ -652,7 +654,7 @@ void do_revenge(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { continue; } - temp[0] = UPPER(temp[0]); + native_text::capitalize_first(temp); // если нада исключаем тех, кто находится оффлайн if (bOnlineOnly) { for (const auto &tch : character_list) { @@ -663,9 +665,9 @@ void do_revenge(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { if (tch->get_uid() == uid) { found = true; if (pk.battle_exp > time(nullptr)) { - sprintf(buf + strlen(buf), " %-40s <БОЕВЫЕ ДЕЙСТВИЯ>\r\n", temp.c_str()); + strcat(buf, fmt::format(" {:<40} <БОЕВЫЕ ДЕЙСТВИЯ>\r\n", temp).c_str()); } else { - sprintf(buf + strlen(buf), " %-40s %3ld %3ld\r\n", temp.c_str(), pk.kill_num, pk.revenge_num); + strcat(buf, fmt::format(" {:<40} {:3} {:3}\r\n", temp, pk.kill_num, pk.revenge_num).c_str()); } break; } @@ -673,9 +675,9 @@ void do_revenge(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { } else { found = true; if (pk.battle_exp > time(nullptr)) { - sprintf(buf + strlen(buf), " %-40s <БОЕВЫЕ ДЕЙСТВИЯ>\r\n", temp.c_str()); + strcat(buf, fmt::format(" {:<40} <БОЕВЫЕ ДЕЙСТВИЯ>\r\n", temp).c_str()); } else { - sprintf(buf + strlen(buf), " %-40s %3ld %3ld\r\n", temp.c_str(), pk.kill_num, pk.revenge_num); + strcat(buf, fmt::format(" {:<40} {:3} {:3}\r\n", temp, pk.kill_num, pk.revenge_num).c_str()); } } } @@ -708,12 +710,12 @@ void do_revenge(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { // Сначала проверка клан флага if (CLAN(ch) && pk.clan_exp > time(nullptr)) { - sprintf(buf + strlen(buf), " %-40s <ВОЙНА>\r\n", GET_NAME(tch)); + strcat(buf, fmt::format(" {:<40} <ВОЙНА>\r\n", GET_NAME(tch)).c_str()); } else if (pk.clan_exp > time(nullptr)) { - sprintf(buf + strlen(buf), " %-40s <ВРЕМЕННЫЙ ФЛАГ>\r\n", GET_NAME(tch)); + strcat(buf, fmt::format(" {:<40} <ВРЕМЕННЫЙ ФЛАГ>\r\n", GET_NAME(tch)).c_str()); } else if (pk.kill_num + pk.revenge_num > 0) { - sprintf(buf + strlen(buf), " %-40s %3ld %3ld\r\n", - GET_NAME(tch), pk.kill_num, pk.revenge_num); + strcat(buf, fmt::format(" {:<40} {:3} {:3}\r\n", + GET_NAME(tch), pk.kill_num, pk.revenge_num).c_str()); } else { continue; } diff --git a/src/gameplay/handlers/alter_remove_poison.cpp b/src/gameplay/handlers/alter_remove_poison.cpp index 417ccdf52c..769f2b633c 100644 --- a/src/gameplay/handlers/alter_remove_poison.cpp +++ b/src/gameplay/handlers/alter_remove_poison.cpp @@ -12,6 +12,7 @@ #include "engine/db/world_objects.h" #include "gameplay/mechanics/liquid.h" #include "utils/logger.h" +#include #include namespace handlers { @@ -19,9 +20,11 @@ namespace handlers { EStageResult AlterRemovePoison(ActionContext &ctx) { ObjData *obj = ctx.ovict; if (obj->get_rnum() < 0) { - char message[100]; - sprintf(message, "неизвестный прототип объекта : %s (VNUM=%d)", obj->get_PName(grammar::ECase::kNom).c_str(), obj->get_vnum()); - mudlog(message, BRF, kLvlBuilder, SYSLOG, 1); + // fmt, а не sprintf в буфер на 100 байт: сам текст занимает 69 байт в UTF-8, на имя + // предмета оставалось 31 -- меньше шестнадцати русских букв (issue #3681, ср. #3751). + mudlog(fmt::format("неизвестный прототип объекта : {} (VNUM={})", + obj->get_PName(grammar::ECase::kNom), obj->get_vnum()), + BRF, kLvlBuilder, SYSLOG, 1); return AlterMsg(ctx, ESpellMsg::kRemovePoisonUnknown); } // issue.potion-hotfix: remove-poison on a drink/food clears its poison LEVEL (kLiquidPoison) and diff --git a/src/gameplay/mechanics/damage.cpp b/src/gameplay/mechanics/damage.cpp index e1d0afb630..4c34fdd308 100644 --- a/src/gameplay/mechanics/damage.cpp +++ b/src/gameplay/mechanics/damage.cpp @@ -821,8 +821,8 @@ int Damage::Process(CharData *ch, CharData *victim) { ev.name = "damage"; ev.ts_unix_ms = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); - ev.attrs["attacker_name"] = observability::EngineStringToUtf8(GET_NAME(ch) ? GET_NAME(ch) : ""); - ev.attrs["victim_name"] = observability::EngineStringToUtf8(GET_NAME(victim) ? GET_NAME(victim) : ""); + ev.attrs["attacker_name"] = GET_NAME(ch) ? GET_NAME(ch) : ""; + ev.attrs["victim_name"] = GET_NAME(victim) ? GET_NAME(victim) : ""; ev.attrs["dam"] = static_cast(dam); ev.attrs["real_dam"] = static_cast(real_dam); ev.attrs["over_dam"] = static_cast(over_dam); @@ -837,9 +837,9 @@ int Damage::Process(CharData *ch, CharData *victim) { // Чармис/поднятая нежить -- атаковал не сам PC, а его подчинённый. // Визуализатору это нужно, чтобы отделить вклад хозяина и слуг. ev.attrs["attacker_is_charmie"] = IsCharmice(ch); - ev.attrs["attacker_master_name"] = observability::EngineStringToUtf8( + ev.attrs["attacker_master_name"] = (IsCharmice(ch) && ch->has_master() && GET_NAME(ch->get_master())) - ? GET_NAME(ch->get_master()) : ""); + ? GET_NAME(ch->get_master()) : ""; observability::EmitToAllSinks(ev); } SendToTC(victim, false, true, true, "&MПолучен урон = %d&n\r\n", dam); diff --git a/src/gameplay/mechanics/depot.cpp b/src/gameplay/mechanics/depot.cpp index c58e031da1..8214c747b3 100644 --- a/src/gameplay/mechanics/depot.cpp +++ b/src/gameplay/mechanics/depot.cpp @@ -2,6 +2,8 @@ // Copyright (c) 2007 Krodo // Part of Bylins http://www.mud.ru +#include +#include "utils/native_text.h" #include "depot.h" #include "engine/db/player_index.h" #include "administration/privilege.h" @@ -215,20 +217,19 @@ std::string generate_purged_text(long uid, int obj_vnum, unsigned int obj_uid) { return out.str(); } - const std::shared_ptr databuf(new char[fsize + 1], std::default_delete()); + std::vector raw(fsize + 1, '\0'); fseek(fl, 0L, SEEK_SET); - if (!databuf - || !fread(databuf.get(), fsize, 1, fl) - || ferror(fl)) { + if (!fread(raw.data(), fsize, 1, fl) || ferror(fl)) { fclose(fl); log("Хранилище: ошибка чтения файла предметов (%s).", filename); return out.str(); } fclose(fl); - char *data = databuf.get(); - data[fsize] = '\0'; + // Граница чтения: файлы хранилищ лежат на диске в KOI8-R (issue #3681). + std::string databuf = native_text::from_disk_text(std::string(raw.data(), fsize)); + char *data = databuf.data(); int error = 0; for (fsize = 0; *data && *data != '$'; fsize++) { @@ -272,7 +273,10 @@ void add_purged_message(long uid, int obj_vnum, unsigned int obj_uid) { log("Хранилище: error open file: %s! (%s %s %d)", name.c_str(), __FILE__, __func__, __LINE__); return; } - file << generate_purged_text(uid, obj_vnum, obj_uid); + // Граница записи: на диск уходит кодировка мира (сейчас KOI8-R), зеркально + // чтению -- иначе первое же сохранение переводит файл в UTF-8, и откат на + // прежнюю сборку становится невозможен (issue #3681). + file << native_text::to_disk(generate_purged_text(uid, obj_vnum, obj_uid)); } void delete_purged_entry(long uid) { @@ -304,8 +308,9 @@ bool show_purged_message(CharData *ch) { return true; } std::ostringstream out; - out << "\r\n" << file.rdbuf(); - SendMsgToChar(out.str(), ch); + out << file.rdbuf(); + // Граница чтения: файл записан в кодировке мира (issue #3681). + SendMsgToChar("\r\n" + native_text::from_disk_text(out.str()), ch); remove(name.c_str()); purged_list.erase(it); need_save_purged_list = true; @@ -546,7 +551,11 @@ void save_timedata() { log("Хранилище: error open file: %s! (%s %s %d)", depot_file, __FILE__, __func__, __LINE__); return; } - file << out.rdbuf(); + // Граница записи: на диск уходит кодировка мира (сейчас KOI8-R), зеркально + // чтению -- иначе первое же сохранение переводит файл в UTF-8, и откат на + // прежнюю сборку становится невозможен (issue #3681). + const std::string on_disk = native_text::to_disk(out.str()); + file.write(on_disk.data(), static_cast(on_disk.size())); file.close(); } @@ -586,7 +595,11 @@ void write_obj_file(const std::string &name, int file_type, const ObjListType &c log("Хранилище: error open file: %s! (%s %s %d)", filename, __FILE__, __func__, __LINE__); return; } - file << out.rdbuf(); + // Граница записи: на диск уходит кодировка мира (сейчас KOI8-R), зеркально + // чтению -- иначе первое же сохранение переводит файл в UTF-8, и откат на + // прежнюю сборку становится невозможен (issue #3681). + const std::string on_disk = native_text::to_disk(out.str()); + file.write(on_disk.data(), static_cast(on_disk.size())); file.close(); } diff --git a/src/gameplay/mechanics/glory.cpp b/src/gameplay/mechanics/glory.cpp index e067e9d926..381629b017 100644 --- a/src/gameplay/mechanics/glory.cpp +++ b/src/gameplay/mechanics/glory.cpp @@ -3,6 +3,8 @@ // Part of Bylins http://www.mud.ru #include "glory.h" +#include "utils/russian_keys.h" +#include "utils/native_text.h" #include "engine/db/player_index.h" #include "administration/privilege.h" #include "utils/grammar/declensions.h" @@ -138,8 +140,10 @@ void GloryNode::copy_glory(const GloryNodePtr& k) { // * Загрузка общего списка славы, проверка на валидность чара, славы, сверка мд5 файла. void load_glory() { const char *glory_file = LIB_USERDATA"glory.lst"; - std::ifstream file(glory_file); - if (!file.is_open()) { + // Граница чтения: файл лежит на диске в кодировке мира, поднимаем его целиком -- + // дальше разбор идёт по нативному тексту и не меняется (issue #3681). + std::istringstream file(native_text::read_data_file(glory_file)); + if (file.str().empty()) { log("Glory: не удалось открыть файл на чтение: %s", glory_file); return; } @@ -281,8 +285,11 @@ void save_glory() { log("Glory: не удалось открыть файл на запись: %s", glory_file); return; } - file << out.rdbuf(); - file.close(); + // Граница записи: на диск уходит кодировка мира (сейчас KOI8-R), зеркально + // чтению -- иначе первое же сохранение переводит файл в UTF-8, и откат на + // прежнюю сборку становится невозможен (issue #3681). + const std::string on_disk = native_text::to_disk(out.str()); + file.write(on_disk.data(), static_cast(on_disk.size())); } // * Аналог бывшего макроса GET_GLORY(). @@ -478,57 +485,57 @@ void parse_add_stat(CharData *ch, int stat) { // * Парс олц меню 'слава'. bool parse_spend_glory_menu(CharData *ch, const char *arg) { - switch (*arg) { - case 'А': - case 'а': + switch (native_text::first_char_code(arg)) { + case rus::kAUpper: + case rus::kA: if (ch->desc->glory->olc_add_str >= 1) { if (!parse_remove_stat(ch, G_STR)) break; ch->desc->glory->olc_str -= 1; ch->desc->glory->olc_add_str -= 1; } break; - case 'Б': - case 'б': + case rus::kBeUpper: + case rus::kBe: if (ch->desc->glory->olc_add_dex >= 1) { if (!parse_remove_stat(ch, G_DEX)) break; ch->desc->glory->olc_dex -= 1; ch->desc->glory->olc_add_dex -= 1; } break; - case 'Г': - case 'г': + case rus::kGeUpper: + case rus::kGe: if (ch->desc->glory->olc_add_int >= 1) { if (!parse_remove_stat(ch, G_INT)) break; ch->desc->glory->olc_int -= 1; ch->desc->glory->olc_add_int -= 1; } break; - case 'Д': - case 'д': + case rus::kDeUpper: + case rus::kDe: if (ch->desc->glory->olc_add_wis >= 1) { if (!parse_remove_stat(ch, G_WIS)) break; ch->desc->glory->olc_wis -= 1; ch->desc->glory->olc_add_wis -= 1; } break; - case 'Е': - case 'е': + case rus::kIeUpper: + case rus::kIe: if (ch->desc->glory->olc_add_con >= 1) { if (!parse_remove_stat(ch, G_CON)) break; ch->desc->glory->olc_con -= 1; ch->desc->glory->olc_add_con -= 1; } break; - case 'Ж': - case 'ж': + case rus::kZheUpper: + case rus::kZhe: if (ch->desc->glory->olc_add_cha >= 1) { if (!parse_remove_stat(ch, G_CHA)) break; ch->desc->glory->olc_cha -= 1; ch->desc->glory->olc_add_cha -= 1; } break; - case 'З': - case 'з': + case rus::kZeUpper: + case rus::kZe: if (ch->desc->glory->olc_node->free_glory >= 1000 && ch->desc->glory->olc_add_spend_glory < MAX_STATS_BY_GLORY) { parse_add_stat(ch, G_STR); @@ -536,8 +543,8 @@ bool parse_spend_glory_menu(CharData *ch, const char *arg) { ch->desc->glory->olc_add_str += 1; } break; - case 'И': - case 'и': + case rus::kIUpper: + case rus::kI: if (ch->desc->glory->olc_node->free_glory >= 1000 && ch->desc->glory->olc_add_spend_glory < MAX_STATS_BY_GLORY) { parse_add_stat(ch, G_DEX); @@ -545,8 +552,8 @@ bool parse_spend_glory_menu(CharData *ch, const char *arg) { ch->desc->glory->olc_add_dex += 1; } break; - case 'К': - case 'к': + case rus::kKaUpper: + case rus::kKa: if (ch->desc->glory->olc_node->free_glory >= 1000 && ch->desc->glory->olc_add_spend_glory < MAX_STATS_BY_GLORY) { parse_add_stat(ch, G_INT); @@ -554,8 +561,8 @@ bool parse_spend_glory_menu(CharData *ch, const char *arg) { ch->desc->glory->olc_add_int += 1; } break; - case 'Л': - case 'л': + case rus::kElUpper: + case rus::kEl: if (ch->desc->glory->olc_node->free_glory >= 1000 && ch->desc->glory->olc_add_spend_glory < MAX_STATS_BY_GLORY) { parse_add_stat(ch, G_WIS); @@ -563,8 +570,8 @@ bool parse_spend_glory_menu(CharData *ch, const char *arg) { ch->desc->glory->olc_add_wis += 1; } break; - case 'М': - case 'м': + case rus::kEmUpper: + case rus::kEm: if (ch->desc->glory->olc_node->free_glory >= 1000 && ch->desc->glory->olc_add_spend_glory < MAX_STATS_BY_GLORY) { parse_add_stat(ch, G_CON); @@ -572,8 +579,8 @@ bool parse_spend_glory_menu(CharData *ch, const char *arg) { ch->desc->glory->olc_add_con += 1; } break; - case 'Н': - case 'н': + case rus::kEnUpper: + case rus::kEn: if (ch->desc->glory->olc_node->free_glory >= 1000 && ch->desc->glory->olc_add_spend_glory < MAX_STATS_BY_GLORY) { parse_add_stat(ch, G_CHA); @@ -581,8 +588,8 @@ bool parse_spend_glory_menu(CharData *ch, const char *arg) { ch->desc->glory->olc_add_cha += 1; } break; - case 'В': - case 'в': { + case rus::kVeUpper: + case rus::kVe: { // проверка, чтобы не записывать зря, а только при изменения // и чтобы нельзя было из стата славу вытащить if ((ch->desc->glory->olc_str == ch->GetInbornStr() @@ -642,8 +649,8 @@ bool parse_spend_glory_menu(CharData *ch, const char *arg) { SendMsgToChar("Ваши изменения сохранены.\r\n", ch); return true; } - case 'Х': - case 'х': ch->desc->glory.reset(); + case rus::kHaUpper: + case rus::kHa: ch->desc->glory.reset(); ch->desc->state = EConState::kPlaying; SendMsgToChar("Редактирование прервано.\r\n", ch); return true; diff --git a/src/gameplay/mechanics/glory_const.cpp b/src/gameplay/mechanics/glory_const.cpp index 6edfc7fb3a..85649e7aa7 100644 --- a/src/gameplay/mechanics/glory_const.cpp +++ b/src/gameplay/mechanics/glory_const.cpp @@ -3,7 +3,10 @@ // Copyright (c) 2010 Krodo // Part of Bylins http://www.mud.ru +#include #include "glory_const.h" +#include "utils/russian_keys.h" +#include "utils/native_text.h" #include "engine/db/player_index.h" #include "administration/privilege.h" #include "utils/grammar/declensions.h" @@ -226,7 +229,8 @@ void print_glory(CharData *ch, GloryListType::iterator &it) { *buf = '\0'; for (auto i = it->second->stats.begin(), iend = it->second->stats.end(); i != iend; ++i) { if ((i->first >= 0) && (i->first < (int) sizeof(olc_stat_name))) { - sprintf(buf + strlen(buf), "%-16s: +%d", olc_stat_name[i->first], i->second * stat_multi(i->first)); + strcat(buf, fmt::format("{:<16}: +{}", olc_stat_name[i->first], + i->second * stat_multi(i->first)).c_str()); if (stat_multi(i->first) > 1) sprintf(buf + strlen(buf), "(%d)", i->second); strcat(buf, "\r\n"); @@ -465,32 +469,32 @@ int olc_real_stat(CharData *ch, int stat) { } bool parse_spend_glory_menu(CharData *ch, char *arg) { - switch (LOWER(*arg)) { - case 'а': olc_del_stat(ch, GLORY_STR); + switch (native_text::first_char_code_lower(arg)) { + case rus::kA: olc_del_stat(ch, GLORY_STR); break; - case 'б': olc_del_stat(ch, GLORY_DEX); + case rus::kBe: olc_del_stat(ch, GLORY_DEX); break; - case 'г': olc_del_stat(ch, GLORY_INT); + case rus::kGe: olc_del_stat(ch, GLORY_INT); break; - case 'д': olc_del_stat(ch, GLORY_WIS); + case rus::kDe: olc_del_stat(ch, GLORY_WIS); break; - case 'е': olc_del_stat(ch, GLORY_CON); + case rus::kIe: olc_del_stat(ch, GLORY_CON); break; - case 'ж': olc_del_stat(ch, GLORY_CHA); + case rus::kZhe: olc_del_stat(ch, GLORY_CHA); break; - case 'з': olc_del_stat(ch, GLORY_HIT); + case rus::kZe: olc_del_stat(ch, GLORY_HIT); break; - case 'и': olc_del_stat(ch, GLORY_SUCCESS); + case rus::kI: olc_del_stat(ch, GLORY_SUCCESS); break; - case 'к': olc_del_stat(ch, GLORY_WILL); + case rus::kKa: olc_del_stat(ch, GLORY_WILL); break; - case 'л': olc_del_stat(ch, GLORY_STABILITY); + case rus::kEl: olc_del_stat(ch, GLORY_STABILITY); break; - case 'м': olc_del_stat(ch, GLORY_REFLEX); + case rus::kEm: olc_del_stat(ch, GLORY_REFLEX); break; - case 'н': olc_del_stat(ch, GLORY_MIND); + case rus::kEn: olc_del_stat(ch, GLORY_MIND); break; - case 'э': olc_del_stat(ch, GLORY_MANAREG); + case rus::kE: olc_del_stat(ch, GLORY_MANAREG); break; case 'x': olc_add_stat(ch, GLORY_BONUSPSYS); break; @@ -500,33 +504,33 @@ bool parse_spend_glory_menu(CharData *ch, char *arg) { break; case 'd': olc_del_stat(ch, GLORY_BONUSMAG); break; - case 'о': olc_add_stat(ch, GLORY_STR); + case rus::kO: olc_add_stat(ch, GLORY_STR); break; - case 'п': olc_add_stat(ch, GLORY_DEX); + case rus::kPe: olc_add_stat(ch, GLORY_DEX); break; - case 'р': olc_add_stat(ch, GLORY_INT); + case rus::kEr: olc_add_stat(ch, GLORY_INT); break; - case 'с': olc_add_stat(ch, GLORY_WIS); + case rus::kEs: olc_add_stat(ch, GLORY_WIS); break; - case 'т': olc_add_stat(ch, GLORY_CON); + case rus::kTe: olc_add_stat(ch, GLORY_CON); break; - case 'у': olc_add_stat(ch, GLORY_CHA); + case rus::kU: olc_add_stat(ch, GLORY_CHA); break; - case 'ф': olc_add_stat(ch, GLORY_HIT); + case rus::kEf: olc_add_stat(ch, GLORY_HIT); break; - case 'х': olc_add_stat(ch, GLORY_SUCCESS); + case rus::kHa: olc_add_stat(ch, GLORY_SUCCESS); break; - case 'ц': olc_add_stat(ch, GLORY_WILL); + case rus::kTse: olc_add_stat(ch, GLORY_WILL); break; - case 'ч': olc_add_stat(ch, GLORY_STABILITY); + case rus::kChe: olc_add_stat(ch, GLORY_STABILITY); break; - case 'ш': olc_add_stat(ch, GLORY_REFLEX); + case rus::kSha: olc_add_stat(ch, GLORY_REFLEX); break; - case 'щ': olc_add_stat(ch, GLORY_MIND); + case rus::kScha: olc_add_stat(ch, GLORY_MIND); break; - case 'ю': olc_add_stat(ch, GLORY_MANAREG); + case rus::kYu: olc_add_stat(ch, GLORY_MANAREG); break; - case 'в': { + case rus::kVe: { // получившиеся статы ch->set_str(olc_real_stat(ch, GLORY_STR)); ch->set_dex(olc_real_stat(ch, GLORY_DEX)); @@ -572,7 +576,7 @@ bool parse_spend_glory_menu(CharData *ch, char *arg) { save(); return 1; } - case 'я': ch->desc->glory_const.reset(); + case rus::kYa: ch->desc->glory_const.reset(); ch->desc->state = EConState::kPlaying; SendMsgToChar("Редактирование прервано.\r\n", ch); return 1; @@ -891,13 +895,20 @@ void save() { spent_node.set_name("total_spent"); spent_node.append_attribute("amount") = total_spent; - doc.save_file(LIB_USERDATA"glory_const.xml"); + // Граница записи: XML уходит на диск в кодировке мира, а не в нативной + // (issue #3681). + std::ostringstream xml; + doc.save(xml, "\t", pugi::format_default, pugi::encoding_utf8); + native_text::write_file(LIB_USERDATA"glory_const.xml", xml.str()); } void load() { int ver = 0; pugi::xml_document doc; - pugi::xml_parse_result result = doc.load_file(LIB_USERDATA"glory_const.xml"); + // Файл лежит на диске в KOI8-R; читаем через границу кодировки, а разбираем уже + // буфер в нативной кодировке движка (issue #3681). Под KOI8-R это тождество. + const std::string xml_glory_const = native_text::read_data_file(LIB_USERDATA"glory_const.xml"); + pugi::xml_parse_result result = doc.load_buffer(xml_glory_const.data(), xml_glory_const.size()); if (!result) { snprintf(buf, kMaxStringLength, "WARNING: glory_const.xml not found or unreadable (%s), skipping (non-fatal)", result.description()); perror(buf); @@ -1126,7 +1137,7 @@ void PrintGloryChart(CharData *ch) { t_it != playerGloryList.end() && i < kPlayerChartSize; ++t_it, ++i) { std::string name = GetNameByUnique(t_it->get()->uid); - name[0] = UPPER(name[0]); + native_text::capitalize_first(name); if (name.length() == 0) { name = "*скрыто*"; } diff --git a/src/gameplay/mechanics/groups.cpp b/src/gameplay/mechanics/groups.cpp index e518c00b26..77d466ebef 100644 --- a/src/gameplay/mechanics/groups.cpp +++ b/src/gameplay/mechanics/groups.cpp @@ -6,6 +6,7 @@ \detail Группы, вступление, покидание, дележка опыта - должно быть тут. */ +#include "utils/native_text.h" #include "gameplay/mechanics/groups.h" #include "utils/grammar/gender.h" #include "utils/grammar/declensions.h" @@ -276,7 +277,7 @@ void group::print_one_line(CharData *ch, CharData *k, int leader, int header) { if (!header) buffer << "Персонаж | Здоровье | Рядом | Аффект | Дебаф | Положение\r\n"; - buffer << fmt::format("&B{:<20}&n|", k->get_name().substr(0, 20)); + buffer << fmt::format("&B{:<20}&n|", k->get_name().substr(0, native_text::char_offset(k->get_name(), 20))); buffer << fmt::format("{}", GetWarmValueColor(k->get_hit(), k->get_real_max_hit())); buffer << fmt::format("{:<10}&n|", WORD_STATE[posi_value(k->get_hit(), k->get_real_max_hit()) + 1]); diff --git a/src/gameplay/mechanics/liquid.cpp b/src/gameplay/mechanics/liquid.cpp index 4b37fdbf03..d44a0cd8c4 100644 --- a/src/gameplay/mechanics/liquid.cpp +++ b/src/gameplay/mechanics/liquid.cpp @@ -20,6 +20,10 @@ #include "engine/db/global_objects.h" #include "poison.h" +#include "utils/utils_string.h" + +#include + #include #include @@ -556,52 +560,58 @@ size_t find_liquid_name(const char *name) { return result; } -void name_from_drinkcon(ObjData *obj) { - char new_name[kMaxStringLength]; - std::string tmp; +// Разделитель между названием ёмкости и названием жидкости: "древний череп" + " с " + "зельем". +// Длину берём отсюда же -- в KOI8-R это три байта, в UTF-8 четыре, а зашитая тройка срезала +// разделитель не целиком и оставляла в названии лишний пробел (issue #3681). +static const std::string kLiquidSeparator = " с "; + +// Имена, которые прежняя обрезка успела испортить, оканчиваются пробелом -- он остался от +// недорезанного разделителя и уехал в файлы вещей. Приписав к такому имени " с ", получаем +// "огромная дубовая бочка с черным колдовским зельем", и так при каждой загрузке. Поэтому +// хвостовые пробелы срезаем с обеих сторон -- и когда название жидкости снимаем, и когда +// добавляем: уже испорченные вещи чинятся сами, а описки билдеров не всплывают в игре. +static std::string CutLiquidName(const std::string &name, size_t pos, size_t separator_len) { + return utils::TrimRightCopy(name.substr(0, pos - separator_len)); +} +void name_from_drinkcon(ObjData *obj) { size_t pos = find_liquid_name(obj->get_aliases().c_str()); - if (pos == std::string::npos) return; - tmp = obj->get_aliases().substr(0, pos - 1); - - sprintf(new_name, "%s", tmp.c_str()); - obj->set_aliases(new_name); + if (pos == std::string::npos || pos < 1) { + return; + } + obj->set_aliases(CutLiquidName(obj->get_aliases(), pos, 1)); pos = find_liquid_name(obj->get_short_description().c_str()); - if (pos == std::string::npos) return; - tmp = obj->get_short_description().substr(0, pos - 3); - - sprintf(new_name, "%s", tmp.c_str()); - obj->set_short_description(new_name); + if (pos == std::string::npos || pos < kLiquidSeparator.length()) { + return; + } + obj->set_short_description(CutLiquidName(obj->get_short_description(), pos, kLiquidSeparator.length())); for (int c = grammar::ECase::kFirstCase; c <= grammar::ECase::kLastCase; c++) { auto name_case = static_cast(c); pos = find_liquid_name(obj->get_PName(name_case).c_str()); - if (pos == std::string::npos) return; - tmp = obj->get_PName(name_case).substr(0, pos - 3); - sprintf(new_name, "%s", tmp.c_str()); - obj->set_PName(name_case, new_name); + if (pos == std::string::npos || pos < kLiquidSeparator.length()) { + return; + } + obj->set_PName(name_case, CutLiquidName(obj->get_PName(name_case), pos, kLiquidSeparator.length())); } } void name_to_drinkcon(ObjData *obj, int type) { - int c; - char new_name[kMaxInputLength], potion_name[kMaxInputLength]; - if (type >= NUM_LIQ_TYPES) { - snprintf(potion_name, kMaxInputLength, "%s", "непонятной бормотухой, сообщите БОГАМ"); - } else { - snprintf(potion_name, kMaxInputLength, "%s", drinknames[type]); - } + const std::string potion_name = (type < 0 || type >= NUM_LIQ_TYPES) + ? "непонятной бормотухой, сообщите БОГАМ" + : drinknames[type]; - snprintf(new_name, kMaxInputLength, "%s %s", obj->get_aliases().c_str(), potion_name); - obj->set_aliases(new_name); - snprintf(new_name, kMaxInputLength, "%s с %s", obj->get_short_description().c_str(), potion_name); - obj->set_short_description(new_name); + obj->set_aliases(fmt::format("{} {}", utils::TrimRightCopy(obj->get_aliases()), potion_name)); + obj->set_short_description(fmt::format("{}{}{}", + utils::TrimRightCopy(obj->get_short_description()), + kLiquidSeparator, potion_name)); - for (c = grammar::ECase::kFirstCase; c <= grammar::ECase::kLastCase; c++) { + for (int c = grammar::ECase::kFirstCase; c <= grammar::ECase::kLastCase; c++) { auto name_case = static_cast(c); - snprintf(new_name, kMaxInputLength, "%s с %s", obj->get_PName(name_case).c_str(), potion_name); - obj->set_PName(name_case, new_name); + obj->set_PName(name_case, fmt::format("{}{}{}", + utils::TrimRightCopy(obj->get_PName(name_case)), + kLiquidSeparator, potion_name)); } // issue #3620: имя сосуда больше не совпадает с прототипом -- без этой пометки olc при правке // прототипа восстановит исходное имя, и сосуд перестанет показывать свое содержимое. diff --git a/src/gameplay/mechanics/named_stuff.cpp b/src/gameplay/mechanics/named_stuff.cpp index 078faf8e55..7d1dde22b5 100644 --- a/src/gameplay/mechanics/named_stuff.cpp +++ b/src/gameplay/mechanics/named_stuff.cpp @@ -2,7 +2,11 @@ // Copyright (c) 2010 WorM // Part of Bylins http://www.mud.ru +#include #include "named_stuff.h" +#include "utils/russian_keys.h" +#include "utils/native_text.h" +#include #include "administration/privilege.h" #include "gameplay/mechanics/minions.h" @@ -64,7 +68,11 @@ void save() { stuf_node.append_attribute("cant_msg_a") = i->second->cant_msg_a.c_str(); } - doc.save_file(LIB_USERDATA"named_items.xml"); + // Граница записи: XML уходит на диск в кодировке мира, а не в нативной + // (issue #3681). + std::ostringstream xml; + doc.save(xml, "\t", pugi::format_default, pugi::encoding_utf8); + native_text::write_file(LIB_USERDATA"named_items.xml", xml.str()); } bool check_named(CharData *ch, const ObjData *obj, const bool simple) { @@ -165,11 +173,15 @@ bool parse_nedit_menu(CharData *ch, char *arg) { if (!*buf1) { return false; } - if ((*buf1 < '1' || *buf1 > '8') && (LOWER(*buf1) != 'в' && LOWER(*buf1) != 'х' && LOWER(*buf1) != 'у')) { + if ((*buf1 < '1' || *buf1 > '8') && (native_text::first_char_code_lower(buf1) != rus::kVe + && native_text::first_char_code_lower(buf1) != rus::kHa + && native_text::first_char_code_lower(buf1) != rus::kU)) { SendMsgToChar(ch, "Неверный параметр %c!\r\n", *buf1); return false; } - if (!*buf2 && LOWER(*buf1) != 'в' && LOWER(*buf1) != 'х' && LOWER(*buf1) != 'у') { + if (!*buf2 && native_text::first_char_code_lower(buf1) != rus::kVe + && native_text::first_char_code_lower(buf1) != rus::kHa + && native_text::first_char_code_lower(buf1) != rus::kU) { if (*buf1 < '5' || *buf1 > '8') { SendMsgToChar("Не указан второй параметр!\r\n", ch); } else { @@ -190,7 +202,7 @@ bool parse_nedit_menu(CharData *ch, char *arg) { return false; } - switch (LOWER(*buf1)) { + switch (native_text::first_char_code_lower(buf1)) { case '1': if (a_isdigit(*buf2) && sscanf(buf2, "%d", &num)) { if (GetObjRnum(num) < 0) { @@ -262,7 +274,7 @@ bool parse_nedit_menu(CharData *ch, char *arg) { } break; - case 'у': + case rus::kU: if (!ch->desc->old_vnum) return false; stuff_list.erase(ch->desc->old_vnum); @@ -271,7 +283,7 @@ bool parse_nedit_menu(CharData *ch, char *arg) { save(); return true; - case 'в': tmp_node->uid = ch->desc->named_obj->uid; + case rus::kVe: tmp_node->uid = ch->desc->named_obj->uid; tmp_node->can_clan = ch->desc->named_obj->can_clan; tmp_node->can_alli = ch->desc->named_obj->can_alli; tmp_node->mail = ch->desc->named_obj->mail; @@ -287,7 +299,7 @@ bool parse_nedit_menu(CharData *ch, char *arg) { save(); return true; - case 'х': ch->desc->state = EConState::kPlaying; + case rus::kHa: ch->desc->state = EConState::kPlaying; SendMsgToChar(CommonMsg(ECommonMsg::kOk) + "\r\n", ch); return true; @@ -369,12 +381,12 @@ void do_named(CharData *ch, char *argument, int cmd, int subcmd) { out += buf1; } found++; - sprintf(buf2, "%6ld) &R*&n%-31s Владелец:%-16s e-mail:&S%s&s\r\n", + strcpy(buf2, fmt::format("{:6}) &R*&n{:<31} Владелец:{:<16} e-mail:&S{}&s\r\n", it->first + 1, "Несуществующий предмет", - GetNameByUnique(it->second->uid, false).c_str(), - str_dup(it->second->mail.c_str()) - ); + GetNameByUnique(it->second->uid, false), + it->second->mail + ).c_str()); out += buf2; } } else { @@ -388,9 +400,9 @@ void do_named(CharData *ch, char *argument, int cmd, int subcmd) { obj_proto[r_num]->get_vnum(), colored_name(obj_proto[r_num]->get_short_description().c_str(), -32)); if (privilege::IsGrGod(ch) || ch->IsFlagged(EPrf::kCoderinfo)) { - snprintf(buf2, kMaxStringLength, "%s Игра:%d Пост:%d Владелец:%-16s e-mail:&S%s&s\r\n", buf1, + strcpy(buf2, fmt::format("{} Игра:{} Пост:{} Владелец:{:<16} e-mail:&S{}&s\r\n", buf1, obj_proto.total_online(r_num), obj_proto.stored(r_num), - GetNameByUnique(it->second->uid, false).c_str(), it->second->mail.c_str()); + GetNameByUnique(it->second->uid, false), it->second->mail).c_str()); } else { snprintf(buf2, kMaxStringLength, "%s\r\n", buf1); } @@ -546,7 +558,10 @@ void load() { stuff_list.clear(); pugi::xml_document doc; - doc.load_file(LIB_USERDATA"named_items.xml"); + // Файл лежит на диске в KOI8-R; читаем через границу кодировки, а разбираем уже + // буфер в нативной кодировке движка (issue #3681). Под KOI8-R это тождество. + const std::string xml_named_stuff = native_text::read_data_file(LIB_USERDATA"named_items.xml"); + doc.load_buffer(xml_named_stuff.data(), xml_named_stuff.size()); pugi::xml_node obj_list = doc.child("named_stuff_list"); for (pugi::xml_node node = obj_list.child("obj"); node; node = node.next_sibling("obj")) { diff --git a/src/gameplay/mechanics/obj_sets_olc.cpp b/src/gameplay/mechanics/obj_sets_olc.cpp index 5a117df6ac..1e09f69ffb 100644 --- a/src/gameplay/mechanics/obj_sets_olc.cpp +++ b/src/gameplay/mechanics/obj_sets_olc.cpp @@ -2,6 +2,8 @@ // Part of Bylins http://www.mud.ru #include "obj_sets.h" +#include "utils/russian_keys.h" +#include "utils/native_text.h" #include "utils/grammar/declensions.h" #include @@ -525,19 +527,19 @@ void sedit::save_olc(CharData *ch) { void parse_main_exit(CharData *ch, const char *arg) { skip_spaces(&arg); - switch (*arg) { + switch (native_text::first_char_code(arg)) { case 'y': case 'Y': - case 'д': - case 'Д': ch->desc->state = EConState::kPlaying; + case rus::kDe: + case rus::kDeUpper: ch->desc->state = EConState::kPlaying; ch->desc->sedit->save_olc(ch); ch->desc->sedit.reset(); SendMsgToChar("Изменения сохранены.\r\n", ch); break; case 'n': case 'N': - case 'н': - case 'Н': ch->desc->sedit.reset(); + case rus::kEn: + case rus::kEnUpper: ch->desc->sedit.reset(); ch->desc->state = EConState::kPlaying; SendMsgToChar("Редактирование отменено.\r\n", ch); break; @@ -551,11 +553,11 @@ void parse_main_exit(CharData *ch, const char *arg) { void parse_set_remove(CharData *ch, const char *arg) { skip_spaces(&arg); - switch (*arg) { + switch (native_text::first_char_code(arg)) { case 'y': case 'Y': - case 'д': - case 'Д': { + case rus::kDe: + case rus::kDeUpper: { for (auto i = sets_list.begin(); i != sets_list.end(); ++i) { if ((*i)->uid == ch->desc->sedit->olc_set.uid) { sets_list.erase(i); @@ -571,8 +573,8 @@ void parse_set_remove(CharData *ch, const char *arg) { } case 'n': case 'N': - case 'н': - case 'Н': SendMsgToChar("Удаление отменено.\r\n", ch); + case rus::kEn: + case rus::kEnUpper: SendMsgToChar("Удаление отменено.\r\n", ch); ch->desc->sedit->show_main(ch); break; default: @@ -585,11 +587,11 @@ void parse_set_remove(CharData *ch, const char *arg) { void sedit::parse_obj_remove(CharData *ch, const char *arg) { skip_spaces(&arg); - switch (*arg) { + switch (native_text::first_char_code(arg)) { case 'y': case 'Y': - case 'д': - case 'Д': { + case rus::kDe: + case rus::kDeUpper: { auto i = olc_set.obj_list.find(obj_edit); if (i != olc_set.obj_list.end()) { olc_set.obj_list.erase(i); @@ -601,8 +603,8 @@ void sedit::parse_obj_remove(CharData *ch, const char *arg) { } case 'n': case 'N': - case 'н': - case 'Н': SendMsgToChar("Удаление отменено.\r\n", ch); + case rus::kEn: + case rus::kEnUpper: SendMsgToChar("Удаление отменено.\r\n", ch); show_obj_edit(ch); break; default: SendMsgToChar("Неверный выбор!\r\n", ch); @@ -613,11 +615,11 @@ void sedit::parse_obj_remove(CharData *ch, const char *arg) { void sedit::parse_activ_remove(CharData *ch, const char *arg) { skip_spaces(&arg); - switch (*arg) { + switch (native_text::first_char_code(arg)) { case 'y': case 'Y': - case 'д': - case 'Д': { + case rus::kDe: + case rus::kDeUpper: { auto i = olc_set.activ_list.find(activ_edit); if (i != olc_set.activ_list.end()) { olc_set.activ_list.erase(i); @@ -629,8 +631,8 @@ void sedit::parse_activ_remove(CharData *ch, const char *arg) { } case 'n': case 'N': - case 'н': - case 'Н': SendMsgToChar("Удаление отменено.\r\n", ch); + case rus::kEn: + case rus::kEnUpper: SendMsgToChar("Удаление отменено.\r\n", ch); show_activ_edit(ch); break; default: SendMsgToChar("Неверный выбор!\r\n", ch); @@ -667,11 +669,11 @@ void sedit::parse_global_msg(CharData *ch, const char *arg) { return; } if (!a_isdigit(*arg)) { - switch (*arg) { + switch (native_text::first_char_code(arg)) { case 'Q': case 'q': - case 'В': - case 'в': + case rus::kVeUpper: + case rus::kVe: if (msg_edit != global_msg) { SendMsgToChar("Вы хотите сохранить изменения? Y(Д)/N(Н) : ", ch); state = STATE_GLOBAL_MSG_EXIT; @@ -721,11 +723,11 @@ void sedit::parse_global_msg(CharData *ch, const char *arg) { void parse_global_msg_exit(CharData *ch, const char *arg) { skip_spaces(&arg); - switch (*arg) { + switch (native_text::first_char_code(arg)) { case 'y': case 'Y': - case 'д': - case 'Д': ch->desc->state = EConState::kPlaying; + case rus::kDe: + case rus::kDeUpper: ch->desc->state = EConState::kPlaying; global_msg = ch->desc->sedit->msg_edit; obj_sets::save(); ch->desc->sedit.reset(); @@ -733,8 +735,8 @@ void parse_global_msg_exit(CharData *ch, const char *arg) { break; case 'n': case 'N': - case 'н': - case 'Н': ch->desc->sedit.reset(); + case rus::kEn: + case rus::kEnUpper: ch->desc->sedit.reset(); ch->desc->state = EConState::kPlaying; SendMsgToChar("Редактирование отменено.\r\n", ch); break; @@ -754,11 +756,11 @@ void sedit::parse_main(CharData *ch, const char *arg) { return; } if (!a_isdigit(*arg)) { - switch (*arg) { + switch (native_text::first_char_code(arg)) { case 'Q': case 'q': - case 'В': - case 'в': + case rus::kVeUpper: + case rus::kVe: if (new_entry || changed()) { SendMsgToChar("Вы хотите сохранить изменения? Y(Д)/N(Н) : ", ch); state = STATE_MAIN_EXIT; @@ -895,7 +897,7 @@ void sedit::parse_setcomment(CharData *ch, const char *arg) { olc_set.comment.clear(); } else { olc_set.comment = arg; - olc_set.comment = olc_set.comment.substr(0, 40); + olc_set.comment = olc_set.comment.substr(0, native_text::char_offset(olc_set.comment, 40)); } show_main(ch); } @@ -1132,11 +1134,11 @@ void sedit::parse_obj_edit(CharData *ch, const char *arg) { return; } if (!a_isdigit(*arg)) { - switch (*arg) { + switch (native_text::first_char_code(arg)) { case 'Q': case 'q': - case 'В': - case 'в': show_main(ch); + case rus::kVeUpper: + case rus::kVe: show_main(ch); break; default: SendMsgToChar("Неверный выбор!\r\n", ch); show_obj_edit(ch); @@ -1337,11 +1339,11 @@ void sedit::parse_activ_edit(CharData *ch, const char *arg) { return; } if (!a_isdigit(*arg)) { - switch (*arg) { + switch (native_text::first_char_code(arg)) { case 'Q': case 'q': - case 'В': - case 'в': show_main(ch); + case rus::kVeUpper: + case rus::kVe: show_main(ch); break; default: SendMsgToChar("Неверный выбор!\r\n", ch); show_activ_edit(ch); diff --git a/src/gameplay/mechanics/saving.cpp b/src/gameplay/mechanics/saving.cpp index 299db95ed9..7c91577290 100644 --- a/src/gameplay/mechanics/saving.cpp +++ b/src/gameplay/mechanics/saving.cpp @@ -5,6 +5,7 @@ */ #include "gameplay/mechanics/saving.h" +#include #include "utils/logger.h" #include "gameplay/mechanics/mount.h" @@ -183,19 +184,30 @@ int CalcGeneralSaving(CharData *killer, CharData *victim, ESaving type, int ext_ } int save = CalcSaving(killer, victim, type, true); int rnd = number(-200, 200); - char smallbuf[256]; + // абсолютный фейл + const bool crit_fail = (number(1, 100) <= 5 + || (AFF_FLAGGED(victim, EAffect::kHold) && type == ESaving::kReflex)); + if (crit_fail) { + save /= 2; + } // Saving-throw debug trace: the 3 hardcoded immortal-name // short-circuits (Верий/Кудояр/Рогоза) were removed -- send_to_TC already gates the // message on EPrf::kTester / kCoderinfo / IsImpl, which is the correct way to opt in. - if (number(1, 100) <= 5 || (AFF_FLAGGED(victim, EAffect::kHold) && type == ESaving::kReflex)) { //абсолютный фейл - save /= 2; - sprintf(smallbuf, "&RПротивник %s (%d), ваш бонус: %d, спас '%s' противника: %d, random -200..200: %d, критудача: ДА, шанс успеха: %2.2f%%.\r\n&n", - GET_NAME(victim), GetRealLevel(victim), ext_apply, saving_name.find(type)->second.c_str(), save, rnd, ((std::clamp(save +ext_apply, -200, 200) + 200) / 400.) * 100.); - spell_trace::Line(killer, nullptr, "%s", smallbuf); - } else { - sprintf(smallbuf, "Противник %s (%d), ваш бонус: %d, спас '%s' противника: %d, random -200..200: %d, критудача: НЕТ, шанс успеха: %2.2f%%.\r\n", - GET_NAME(victim), GetRealLevel(victim), ext_apply, saving_name.find(type)->second.c_str(), save, rnd, ((std::clamp(save +ext_apply, -200, 200) + 200) / 400.) * 100.); - spell_trace::Line(killer, nullptr, "%s", smallbuf); + // + // Строка собирается через fmt::format и только если её кто-то увидит: sprintf клал русский + // текст в буфер на 256 байт, а в UTF-8 одна эта фраза с длинным именем моба в него не влезает + // -- fortify ловил переполнение и валил процесс (issue #3751). + if (spell_trace::Active(killer, nullptr)) { + const std::string trace = fmt::format( + "{}Противник {} ({}), ваш бонус: {}, спас '{}' противника: {}, " + "random -200..200: {}, критудача: {}, шанс успеха: {:.2f}%.\r\n{}", + crit_fail ? "&R" : "", + GET_NAME(victim), GetRealLevel(victim), ext_apply, + saving_name.find(type)->second, save, rnd, + crit_fail ? "ДА" : "НЕТ", + ((std::clamp(save + ext_apply, -200, 200) + 200) / 400.) * 100., + crit_fail ? "&n" : ""); + spell_trace::Line(killer, nullptr, "%s", trace.c_str()); } save += ext_apply; // внешний модификатор (обычно +каст) diff --git a/src/gameplay/mechanics/sets_drop.cpp b/src/gameplay/mechanics/sets_drop.cpp index ad7493ec83..33d23f3c7b 100644 --- a/src/gameplay/mechanics/sets_drop.cpp +++ b/src/gameplay/mechanics/sets_drop.cpp @@ -1,7 +1,9 @@ // Copyright (c) 2012 Krodo // Part of Bylins http://www.mud.ru +#include #include "sets_drop.h" +#include "utils/native_text.h" #include #include @@ -216,7 +218,10 @@ void create_clone_miniset(int vnum) { // * Инициализация списка сетов на лоад. void init_obj_list() { pugi::xml_document doc; - pugi::xml_parse_result result = doc.load_file(CONFIG_FILE); + // Файл лежит на диске в KOI8-R; читаем через границу кодировки, а разбираем уже + // буфер в нативной кодировке движка (issue #3681). Под KOI8-R это тождество. + const std::string xml_sets_drop = native_text::read_data_file(CONFIG_FILE); + pugi::xml_parse_result result = doc.load_buffer(xml_sets_drop.data(), xml_sets_drop.size()); if (!result) { snprintf(buf, kMaxStringLength, "...%s", result.description()); mudlog(buf, CMP, kLvlImmortal, SYSLOG, true); @@ -960,7 +965,11 @@ void save_unique_mobs() { mob_node.append_attribute("vnum") = it->first; mob_node.append_attribute("level") = it->second; } - doc.save_file(MUD::StateManager().Path(state::EStateFile::kUniqueMobs).c_str()); + // Граница записи: XML уходит на диск в кодировке мира, а не в нативной + // (issue #3681). + std::ostringstream xml; + doc.save(xml, "\t", pugi::format_default, pugi::encoding_utf8); + native_text::write_file(MUD::StateManager().Path(state::EStateFile::kUniqueMobs), xml.str()); } void save_drop_table() { diff --git a/src/gameplay/mechanics/sight.cpp b/src/gameplay/mechanics/sight.cpp index 58882327c6..077aca654e 100644 --- a/src/gameplay/mechanics/sight.cpp +++ b/src/gameplay/mechanics/sight.cpp @@ -7,6 +7,7 @@ #include "engine/core/char_movement.h" #include "gameplay/affects/obj_affects.h" +#include "utils/native_text.h" #include "engine/core/target_resolver.h" #include "sight.h" #include "gameplay/mechanics/hide.h" @@ -912,12 +913,18 @@ void do_auto_exits(CharData *ch) { // Наконец-то добавлена отрисовка в автовыходах закрытых дверей if (EXIT(ch, door) && EXIT(ch, door)->to_room() != kNowhere) { if (EXIT_FLAGGED(EXIT(ch, door), EExitFlag::kClosed)) { - slen += sprintf(buf + slen, "(%c) ", LOWER(*dirs[door])); + // Печатаем первую БУКВУ направления: под UTF-8 у русской буквы два байта, и %c + // выводил половину (issue #3681). + slen += sprintf(buf + slen, "("); + slen += static_cast(native_text::copy_lower_char(dirs[door], buf + slen)); + slen += sprintf(buf + slen, ") "); } else if (!EXIT_FLAGGED(EXIT(ch, door), EExitFlag::kHidden)) { if (world[EXIT(ch, door)->to_room()]->zone_rn == world[ch->in_room]->zone_rn) { - slen += sprintf(buf + slen, "%c ", LOWER(*dirs[door])); + slen += static_cast(native_text::copy_lower_char(dirs[door], buf + slen)); + slen += sprintf(buf + slen, " "); } else { - slen += sprintf(buf + slen, "%c ", UPPER(*dirs[door])); + slen += static_cast(native_text::copy_upper_char(dirs[door], buf + slen)); + slen += sprintf(buf + slen, " "); } } } @@ -1333,13 +1340,20 @@ const char *show_obj_to_char(ObjData *object, CharData *ch, int mode, int show_s sprintf(buf2, " %s*%s%s", kColorGrn, kColorNrm, diag_obj_to_char(object, 1)); } else { - sprintf(buf2, " %s ", diag_obj_to_char(object, 1)); - if (object->get_type() == EObjType::kLiquidContainer) { + // diag_obj_to_char сама начинается с пробела, а всё, что дописывается следом, + // свой пробел тоже приносит: лишний тут давал "бочка <великолепно>" и + // "сундук <хорошо> (есть содержимое)". + sprintf(buf2, "%s", diag_obj_to_char(object, 1)); + // В списке предметов от наполнения остаётся только пометка "(пусто)". Полная + // фраза ("наполнена меньше, чем на четверть черной вязкой жидкостью") удлиняла + // строку вдвое, а посмотреть её можно, осмотрев ёмкость. + if (object->get_type() == EObjType::kLiquidContainer + && GET_OBJ_VAL(object, 1) <= 0) { char *tmp = drinkcon::daig_filling_drink(object, ch); - char tmp2[128]; - *tmp = LOWER(*tmp); - sprintf(tmp2, "(%s)", tmp); - strcat(buf2, tmp2); + native_text::copy_lower_char(tmp, tmp); + // Без промежуточного буфера: fortify ловил переполнение на длинной фразе + // и валил процесс на осмотре ёмкости (issue #3752). + strcat(buf2, fmt::format(" ({})", tmp).c_str()); } } } @@ -1358,7 +1372,7 @@ const char *show_obj_to_char(ObjData *object, CharData *ch, int mode, int show_s } } else if (mode >= 2 && how <= 1) { std::string obj_name = OBJN(object, ch, grammar::ECase::kNom); - obj_name[0] = UPPER(obj_name[0]); + native_text::capitalize_first(obj_name); if (object->get_type() == EObjType::kLightSource) { if (GET_OBJ_VAL(object, 2) == -1) { sprintf(buf2, "\r\n%s дает вечный свет.", obj_name.c_str()); diff --git a/src/gameplay/mechanics/title.cpp b/src/gameplay/mechanics/title.cpp index f539269f44..1a482eaf66 100644 --- a/src/gameplay/mechanics/title.cpp +++ b/src/gameplay/mechanics/title.cpp @@ -2,6 +2,7 @@ // Copyright (c) 2006 Krodo // Part of Bylins http://www.bylins.su +#include "utils/native_text.h" #include "title.h" #include "gameplay/economics/currencies.h" #include "engine/db/player_index.h" @@ -134,8 +135,8 @@ void TitleSystem::do_title(CharData *ch, char *argument, int/* cmd*/, int/* subc utils::Trim(title); utils::Trim(pre_title); if (!pre_title.empty()) { - sprintf(buf2, "%c%s", UPPER(pre_title.substr(0, 1).c_str()[0]), pre_title.substr(1).c_str()); - pre_title = buf2; + // Поднимаем первую букву целиком, а не первый байт (issue #3681). + native_text::capitalize_first(pre_title); } if (!pre_title.empty() && !check_pre_title(pre_title, ch)) return; if (!title.empty() && !check_title(title, ch)) return; @@ -259,15 +260,23 @@ bool TitleSystem::check_pre_title(const std::string& text, CharData *ch) { * \return 0 не сканало, 1 сканало */ bool TitleSystem::check_alphabet(const std::string &text, CharData *ch, const std::string &allowed) { - int i = 0; - std::string::size_type idx; - for (std::string::const_iterator it = text.begin(); it != text.end(); ++it, ++i) { - unsigned char c = static_cast(*it); - idx = allowed.find(*it); - if (c < 192 && idx == std::string::npos) { - SendMsgToChar(ch, "Недопустимый символ '%c' в позиции %d.\r\n", *it, ++i); - return false; + // Проверяем по символам, а не по байтам. Раньше условие было "байт >= 192", то есть + // кириллица KOI8-R; под UTF-8 у русской буквы два байта, и хвостовой (0x80..0xBF) попадал + // в "меньше 192" -- титул с кириллицей отвергался на второй позиции (issue #3681). + // + // Набор букв оставлен ровно прежним: диапазон 0xC0..0xFF в KOI8-R -- это а-я и А-Я без "ё", + // поэтому "ё" по-прежнему допустима только если перечислена в allowed. + int position = 0; + for (const std::string_view symbol : native_text::chars(text)) { + ++position; + const char32_t code = native_text::first_char_code(std::string(symbol).c_str()); + const bool russian_letter = (code >= 0x0410 && code <= 0x044F); + if (russian_letter || native_text::list_contains_char(allowed, symbol)) { + continue; } + SendMsgToChar(ch, "Недопустимый символ '%.*s' в позиции %d.\r\n", + static_cast(symbol.size()), symbol.data(), position); + return false; } return true; } @@ -412,9 +421,15 @@ void TitleSystem::save_title_list() { log("Error open file: %s! (%s %s %d)", title_file.c_str(), __FILE__, __func__, __LINE__); return; } + std::ostringstream out; for (TitleListType::const_iterator it = title_list.begin(); it != title_list.end(); ++it) - file << it->first << " " << it->second->unique << "\n" << it->second->pre_title << "\n" << it->second->title - << "\n"; + out << it->first << " " << it->second->unique << "\n" << it->second->pre_title << "\n" << it->second->title + << "\n"; + // Граница записи: на диск уходит кодировка мира (сейчас KOI8-R), зеркально + // чтению -- иначе первое же сохранение переводит файл в UTF-8, и откат на + // прежнюю сборку становится невозможен (issue #3681). + const std::string on_disk = native_text::to_disk(out.str()); + file.write(on_disk.data(), static_cast(on_disk.size())); file.close(); } @@ -435,10 +450,11 @@ void TitleSystem::load_title_list() { std::getline(file, pre_title); std::getline(file, title); WaitingTitlePtr temp(new waiting_title); - temp->title = title; - temp->pre_title = pre_title; + // Граница чтения: список лежит на диске в кодировке мира (issue #3681). + temp->title = native_text::from_disk_line(title.c_str()); + temp->pre_title = native_text::from_disk_line(pre_title.c_str()); temp->unique = unique; - title_list[name] = temp; + title_list[native_text::from_disk_line(name.c_str())] = temp; } file.close(); } diff --git a/src/gameplay/quests/quested.cpp b/src/gameplay/quests/quested.cpp index 4a3409ef35..105d23a8f6 100644 --- a/src/gameplay/quests/quested.cpp +++ b/src/gameplay/quests/quested.cpp @@ -8,6 +8,7 @@ #include "utils/buffered_file_writer.h" #include "engine/entities/char_data.h" +#include "utils/native_text.h" void smash_tilde(char *str); @@ -19,8 +20,10 @@ void Quested::add(CharData *ch, int vnum, char *text) { skip_spaces(&text); std::string text_node = *text ? text : ""; + // Предел байтовый (это бюджет буфера триггерной строки), но рубить символ + // пополам нельзя -- truncate_offset отступает до границы (issue #3681). if (text_node.size() > kMaxTrglineLength) { - text_node = text_node.substr(0, kMaxTrglineLength); + text_node = text_node.substr(0, native_text::truncate_offset(text_node, kMaxTrglineLength)); } quested_[vnum] = text_node; } diff --git a/src/gameplay/statistics/dps.cpp b/src/gameplay/statistics/dps.cpp index 4ef9cc8eb0..fd5c59f272 100644 --- a/src/gameplay/statistics/dps.cpp +++ b/src/gameplay/statistics/dps.cpp @@ -2,6 +2,7 @@ // Copyright (c) 2009 Krodo // Part of Bylins http://www.mud.ru +#include "utils/native_text.h" #include "dps.h" #include "gameplay/core/remort.h" #include "gameplay/mechanics/minions.h" @@ -32,7 +33,7 @@ void DpsNode::set_name(const char *name) { if (name && *name) { name_ = name; if (name_.size() > 25) { - name_ = name_.substr(0, 25); + name_ = name_.substr(0, native_text::char_offset(name_, 25)); } } } @@ -134,7 +135,7 @@ void Dps::Clear(int type) { struct sort_node { sort_node(const std::string &in_name, int in_dps, unsigned in_round_dmg, unsigned in_over_dmg) : dps(in_dps), round_dmg(in_round_dmg), over_dmg(in_over_dmg) { - name = in_name.substr(0, 25); + name = in_name.substr(0, native_text::char_offset(in_name, 25)); }; std::string name; diff --git a/src/gameplay/statistics/mob_stat.cpp b/src/gameplay/statistics/mob_stat.cpp index 3db05fcf1d..5c51b8c4df 100644 --- a/src/gameplay/statistics/mob_stat.cpp +++ b/src/gameplay/statistics/mob_stat.cpp @@ -2,6 +2,7 @@ // Part of Bylins http://www.mud.ru #include "mob_stat.h" +#include "utils/native_text.h" #include "third_party_libs/pugixml/pugixml.h" #include "utils/utils_parse.h" @@ -57,7 +58,7 @@ void AddClassExp(ECharClass class_id, int exp) { std::string PrintClassExpStat(const ECharClass id, unsigned long long top_exp) { std::ostringstream out; - out << std::left << std::setw(15) << MUD::Class(id).GetPluralName() << " " << std::left << kColorBoldCyn; + out << fmt::format("{:<15}", MUD::Class(id).GetPluralName()) << " " << std::left << kColorBoldCyn; const int points_amount{10}; int stars{0}; if (top_exp > 0) { @@ -272,7 +273,10 @@ static void LoadXmlLegacy() { char buf_[kMaxInputLength]; pugi::xml_document doc; - pugi::xml_parse_result result = doc.load_file(MUD::StateManager().Path(state::EStateFile::kMobStat).c_str()); + // Файл лежит на диске в KOI8-R; читаем через границу кодировки, а разбираем уже + // буфер в нативной кодировке движка (issue #3681). Под KOI8-R это тождество. + const std::string xml_mob_stat = native_text::read_data_file(MUD::StateManager().Path(state::EStateFile::kMobStat)); + pugi::xml_parse_result result = doc.load_buffer(xml_mob_stat.data(), xml_mob_stat.size()); if (!result) { snprintf(buf_, sizeof(buf_), "...%s", result.description()); mudlog(buf_, CMP, kLvlImmortal, SYSLOG, true); @@ -520,8 +524,7 @@ void ShowZoneMobKillsStat(CharData *ch, ZoneVnum zone_vnum, int months) { " vnum : имя : pk : группа = убийств (n3=100 моба убили 100 раз втроем)\r\n\r\n"; for (auto & i : sort_list) { - out << i.first << " : " << std::setw(20) - << PrintMobName(i.first, 20) << " : " + out << i.first << " : " << fmt::format("{:>20}", PrintMobName(i.first, 20)) << " : " << i.second.kills.at(0) << " :"; for (int g = 1; g <= kMaxGroupSize; ++g) { if (i.second.kills.at(g) > 0) { diff --git a/src/gameplay/statistics/spell_usage.cpp b/src/gameplay/statistics/spell_usage.cpp index 7838eaf434..cbdbadb8d0 100644 --- a/src/gameplay/statistics/spell_usage.cpp +++ b/src/gameplay/statistics/spell_usage.cpp @@ -6,6 +6,7 @@ \detail Detail description. */ +#include #include "spell_usage.h" #include "engine/boot/boot_data_files.h" @@ -31,9 +32,9 @@ std::string SpellUsage::StatToPrint() { char *end_time = str_dup(rustime(localtime(&now))); out << rustime(localtime(&SpellUsage::start)) << " - " << end_time << "\n"; for (auto & it : SpellUsage::usage) { - out << std::setw(35) << MUD::Class(it.first).GetName() << "\r\n"; + out << fmt::format("{:>35}", MUD::Class(it.first).GetName()) << "\r\n"; for (auto & itt : it.second) { - out << std::setw(25) << MUD::Spell(itt.first).GetName() << " : " << itt.second << "\r\n"; + out << fmt::format("{:>25}", MUD::Spell(itt.first).GetName()) << " : " << itt.second << "\r\n"; } } return out.str(); diff --git a/src/simulator/scenario_runner.cpp b/src/simulator/scenario_runner.cpp index b458ee6960..1a6bc76cbd 100644 --- a/src/simulator/scenario_runner.cpp +++ b/src/simulator/scenario_runner.cpp @@ -124,9 +124,9 @@ class HeadlessDescriptor { e.ts_unix_ms = NowUnixMs(); e.attrs["round"] = static_cast(round_no); e.attrs["role"] = std::string(role); - e.attrs["target_name"] = observability::EngineStringToUtf8( - GET_NAME(ch_) ? GET_NAME(ch_) : ""); - e.attrs["text"] = observability::EngineStringToUtf8(text); + e.attrs["target_name"] = + GET_NAME(ch_) ? GET_NAME(ch_) : ""; + e.attrs["text"] = text; observability::EmitToAllSinks(e); } ResetBuffer(); @@ -379,7 +379,7 @@ void AddParticipantAttrs(observability::Event& e, const char* role, const Partic using T = std::decay_t; if constexpr (std::is_same_v) { e.attrs[std::string(role) + "_type"] = std::string("player"); - e.attrs[std::string(role) + "_class"] = observability::EngineStringToUtf8(s.class_name); + e.attrs[std::string(role) + "_class"] = s.class_name; e.attrs[std::string(role) + "_level"] = static_cast(s.level); } else if constexpr (std::is_same_v) { e.attrs[std::string(role) + "_type"] = std::string("mob"); @@ -399,16 +399,16 @@ void EmitCharState(const char* role, e.ts_unix_ms = NowUnixMs(); e.attrs["round"] = static_cast(round_no); e.attrs["role"] = std::string(role); - e.attrs["target_name"] = observability::EngineStringToUtf8( - GET_NAME(ch) ? GET_NAME(ch) : ""); + e.attrs["target_name"] = + GET_NAME(ch) ? GET_NAME(ch) : ""; // Identity: PC vs NPC + display fields. У PC имя задано simulator'ом // ('attacker' / 'victim'), уровень и класс полезны для контекста. // У моба важен vnum + short_descr (полное имя из прототипа). e.attrs["is_npc"] = ch->IsNpc(); if (ch->IsNpc()) { e.attrs["vnum"] = static_cast(GET_MOB_VNUM(ch)); - e.attrs["short_descr"] = observability::EngineStringToUtf8( - ch->get_npc_name()); + e.attrs["short_descr"] = + ch->get_npc_name(); } else { e.attrs["vnum"] = static_cast(-1); e.attrs["short_descr"] = std::string(); @@ -417,8 +417,8 @@ void EmitCharState(const char* role, // Реморт PC -- часть "уровня прокачки", влияет на выданные скиллы // и фиты. Веб-UI показывает рядом с уровнем. e.attrs["remort"] = static_cast(ch->IsNpc() ? 0 : ch->get_remort()); - e.attrs["class_name"] = observability::EngineStringToUtf8( - ch->IsNpc() ? std::string() : MUD::Class(ch->GetClass()).GetName()); + e.attrs["class_name"] = + ch->IsNpc() ? std::string() : MUD::Class(ch->GetClass()).GetName(); e.attrs["hp"] = static_cast(ch->get_hit()); e.attrs["max_hp"] = static_cast(ch->get_max_hit()); e.attrs["move"] = static_cast(ch->get_move()); @@ -467,7 +467,7 @@ void EmitCharState(const char* role, feats_list += feat.GetName(); } } - e.attrs["feats_list"] = observability::EngineStringToUtf8(feats_list); + e.attrs["feats_list"] = feats_list; // Список активных аффектов с их типом (spell name) и оставшейся // длительностью; '|'-разделитель -- web-UI парсит и рисует chip'ами. std::string aff_list; @@ -479,7 +479,7 @@ void EmitCharState(const char* role, aff_list += fmt::format(" ({}t)", a->duration); } e.attrs["affects_count"] = static_cast(aff_count); - e.attrs["affects_list"] = observability::EngineStringToUtf8(aff_list); + e.attrs["affects_list"] = aff_list; // Список одетых предметов: 'slot:vnum:name', разделители '|'. Веб-UI // рендерит их в state-панели чтобы было видно, чем именно бьётся / // защищается персонаж. @@ -498,7 +498,7 @@ void EmitCharState(const char* role, obj->get_vnum(), obj->get_short_description()); } - e.attrs["equip_list"] = observability::EngineStringToUtf8(equip_list); + e.attrs["equip_list"] = equip_list; e.attrs["aff_silence"] = AFF_FLAGGED(ch, EAffect::kSilence) ? true : false; e.attrs["aff_charmed"] = AFF_FLAGGED(ch, EAffect::kCharmed) ? true : false; e.attrs["aff_sleep"] = AFF_FLAGGED(ch, EAffect::kSleep) ? true : false; @@ -517,7 +517,7 @@ void EmitCharState(const char* role, // FlagData::sprintbits пишет nothing_string ("ничего") когда // ни одного флага не выставлено -- для UI пустая строка лучше. if (s == "ничего") s.clear(); - e.attrs["flags_list"] = observability::EngineStringToUtf8(s); + e.attrs["flags_list"] = s; } observability::EmitToAllSinks(e); } diff --git a/src/utils/diskio.cpp b/src/utils/diskio.cpp index f60cd12e44..902407e4b0 100644 --- a/src/utils/diskio.cpp +++ b/src/utils/diskio.cpp @@ -10,6 +10,9 @@ **************************************************************************/ #include +#include +#include +#include "utils/native_text.h" #include "engine/core/sysdep.h" #include "utils/utils.h" #include "diskio.h" @@ -146,6 +149,24 @@ FBFILE *fbopen_for_read(char *fname) { strcpy(fbfl->name, fname); auto dummy = fread(fbfl->buf, sizeof(char), fbfl->size, fl); + fbfl->raw = fbfl->buf; + fbfl->raw_size = fbfl->size; + + // Данные на диске лежат в KOI8-R. Поднимаем весь буфер до нативной кодировки движка здесь, + // где размер под нашим контролем -- построчная конверсия рисковала бы переполнить буфер + // вызывающего, ведь кириллица при перекодировке удлиняется. Исходные байты остаются в raw + // для CRC. + { + const std::string native = native_text::from_disk_line(fbfl->buf); + if (native.size() != static_cast(fbfl->size)) { + char *converted = nullptr; + CREATE(converted, native.size() + 1); + memcpy(converted, native.c_str(), native.size() + 1); + fbfl->buf = converted; + fbfl->size = static_cast(native.size()); + fbfl->ptr = fbfl->buf; + } + } UNUSED_ARG(dummy); fclose(fl); @@ -193,6 +214,10 @@ size_t fbclose_for_read(FBFILE *fbfl) { return 0; } + // buf мог быть перекодирован в отдельный буфер; raw при этом указывает на исходный. + if (fbfl->raw && fbfl->raw != fbfl->buf) { + free(fbfl->raw); + } if (fbfl->buf) { free(fbfl->buf); } diff --git a/src/utils/diskio.h b/src/utils/diskio.h index 8d1bb08ad6..dfc563586c 100644 --- a/src/utils/diskio.h +++ b/src/utils/diskio.h @@ -38,6 +38,11 @@ typedef struct { int size; // size in bytes of buffer // int flags; // read/write/append, future expansion // char *name; // filename (for delayed writing) // + // Исходные байты файла, как они лежат на диске. buf может быть перекодирован в нативную + // кодировку движка (issue #3681), а CRC файлов игроков считается по ИСХОДНОМУ содержимому, + // иначе сумма разъедется. Под KOI8-R это тот же указатель, что и buf. + char *raw; // original on-disk bytes (for CRC) // + int raw_size; // size of raw in bytes // } FBFILE; void ExtractTagFromArgument(char *argument, char *tag); diff --git a/src/utils/logger.cpp b/src/utils/logger.cpp index ad3a0bd2ce..9715c18b3a 100644 --- a/src/utils/logger.cpp +++ b/src/utils/logger.cpp @@ -6,6 +6,7 @@ #include "gameplay/mechanics/minions.h" #include "engine/entities/char_data.h" #include "utils/utils_encoding.h" +#include "utils/native_text.h" #include "utils/utils_string.h" #include "engine/ui/color.h" #include "backtrace.h" @@ -30,6 +31,25 @@ // дескрипторы открытых файлов логов для сброса буфера при креше std::list opened_files; +namespace { +// Граница записи для логов, которые пишутся прямо в файл мимо LogManager (shop/olc/imm +// и персональные). Логи -- внешне видимые файлы и остаются в кодировке мира, KOI8-R, +// поэтому текст переводится здесь, а не уходит нативным (issue #3681). +void vfprintf_on_disk(FILE *file, const char *format, va_list args) { + va_list measure; + va_copy(measure, args); + const int need = vsnprintf(nullptr, 0, format, measure); + va_end(measure); + if (need < 0) { + return; + } + std::string text(static_cast(need) + 1, '\0'); + vsnprintf(&text[0], text.size(), format, args); + text.resize(static_cast(need)); + fputs(native_text::to_disk(text).c_str(), file); +} +} // namespace + void pers_log(CharData *ch, const char *format, ...) { if (!ch) { log("NULL character resieved! (%s %s %d)", __FILE__, __func__, __LINE__); @@ -41,12 +61,12 @@ void pers_log(CharData *ch, const char *format, ...) { } if (!ch->desc->pers_log) { - char filename[128], name[64], *ptr; - strcpy(name, GET_NAME(ch)); - for (ptr = name; *ptr; ptr++) { - *ptr = LOWER(codepages::AtoL(*ptr)); - } - sprintf(filename, "%s/perslog/%s.log", runtime_config.log_dir().c_str(), name); + char filename[128]; + // Имя файла персонального лога строится из байтов KOI8-R, как и до миграции + // (issue #3681). + std::string name = GET_NAME(ch); + CreateFileName(name); + sprintf(filename, "%s/perslog/%s.log", runtime_config.log_dir().c_str(), name.c_str()); ch->desc->pers_log = fopen(filename, "a"); if (!ch->desc->pers_log) { log("SYSERR: error open %s (%s %s %d)", filename, __FILE__, __func__, __LINE__); @@ -58,7 +78,7 @@ void pers_log(CharData *ch, const char *format, ...) { write_time(ch->desc->pers_log); va_list args; va_start(args, format); - vfprintf(ch->desc->pers_log, format, args); + vfprintf_on_disk(ch->desc->pers_log, format, args); va_end(args); fprintf(ch->desc->pers_log, "\n"); } @@ -232,7 +252,7 @@ void shop_log(const char *format, ...) { write_time(file); va_list args; va_start(args, format); - vfprintf(file, format, args); + vfprintf_on_disk(file, format, args); va_end(args); fprintf(file, "\n"); @@ -254,7 +274,7 @@ void olc_log(const char *format, ...) { write_time(file); va_list args; va_start(args, format); - vfprintf(file, format, args); + vfprintf_on_disk(file, format, args); va_end(args); fprintf(file, "\n"); @@ -276,7 +296,7 @@ void imm_log(const char *format, ...) { write_time(file); va_list args; va_start(args, format); - vfprintf(file, format, args); + vfprintf_on_disk(file, format, args); va_end(args); fprintf(file, "\n"); diff --git a/src/utils/logging/file_log_sender.cpp b/src/utils/logging/file_log_sender.cpp index ff54a0f81f..bbf543d40a 100644 --- a/src/utils/logging/file_log_sender.cpp +++ b/src/utils/logging/file_log_sender.cpp @@ -2,15 +2,20 @@ #include "utils/logger.h" #include "engine/core/config.h" #include "engine/db/global_objects.h" +#include "utils/native_text.h" #include namespace logging { -static void write_log_message(const std::string& message, FILE* file) { +static void write_log_message(const std::string& native_message, FILE* file) { if (!file) { return; } + // Граница записи: логи -- внешне видимый файл, и кодировка у них прежняя, KOI8-R. + // Дальше syslog_converter (koi_to_win/koi_to_alt) правит буфер на месте, считая + // байты кои-восьмыми, так что перевести надо именно здесь (issue #3681). + const std::string message = native_text::to_disk(native_message); if (!runtime_config.output_thread() && runtime_config.log_stderr().empty()) { fputs(message.c_str(), file); fputs("\n", file); diff --git a/src/utils/mud_string.cpp b/src/utils/mud_string.cpp index 5a9fcd3d98..6bd64f25be 100644 --- a/src/utils/mud_string.cpp +++ b/src/utils/mud_string.cpp @@ -1,6 +1,7 @@ #include "mud_string.h" #include "utils.h" +#include "utils/native_text.h" int search_block(const char *target_string, const char **list, int exact); @@ -45,9 +46,13 @@ T one_argument_template(T argument, char *first_arg) { do { skip_spaces(&argument); first_arg = begin; + // Lowercase one whole character at a time (issue #3681). The a_isspace() test stays + // byte-based on purpose: it only ever runs at a character boundary, and every + // whitespace character is ASCII, so no multibyte lead byte can be mistaken for one. while (*argument && !a_isspace(*argument)) { - *(first_arg++) = a_lcc(*argument); - argument++; + const size_t n = native_text::copy_lower_char(argument, first_arg); + first_arg += n; + argument += n; } *first_arg = '\0'; } while (fill_word(begin)); @@ -64,11 +69,12 @@ T any_one_arg_template(T argument, char *first_arg) { skip_spaces(&argument); int num = 0; + // As above: one character per step, `num` still counts bytes so it remains a buffer guard. while (*argument && !a_isspace(*argument) && num < kMaxStringLength - 1) { - *first_arg = a_lcc(*argument); - ++first_arg; - ++argument; - ++num; + const size_t n = native_text::copy_lower_char(argument, first_arg); + first_arg += n; + argument += n; + num += static_cast(n); } *first_arg = '\0'; skip_spaces(&argument); diff --git a/src/utils/native_text.cpp b/src/utils/native_text.cpp new file mode 100644 index 0000000000..74b0f6c9b7 --- /dev/null +++ b/src/utils/native_text.cpp @@ -0,0 +1,655 @@ +/** +\file native_text.cpp - a part of the Bylins engine. +\brief Native-encoding character helpers declared in native_text.h (issue #3681). + +The hot paths (case folding, comparison) walk bytes directly instead of decoding to code points +and back: the straightforward version cost 17x on a string-heavy benchmark. +*/ + +#include "native_text.h" + +#include +#include "utf8.h" +#include "utils_encoding.h" +#include "translit_koi8.h" + +#include "utf8.h" + +#include "logger.h" + +#include +#include +#include +#include +#include +#include + +namespace native_text { + + +std::size_t char_count(const char *begin, const char *end) { + return utf8::length(std::string_view(begin, static_cast(end - begin))); +} + +std::size_t char_count(std::string_view s) { + return utf8::length(s); +} + +void capitalize_first(char *s) { + if (s == nullptr || *s == '\0') { + return; + } + const std::string_view sv(s); + char32_t cp = 0; + const std::size_t len = utf8::decode(sv, 0, cp); + if (len == 0) { + return; + } + const char32_t upper = utf8::to_upper(cp); + if (upper == cp) { + return; + } + std::string encoded; + if (utf8::encode(upper, encoded) == len) { + for (std::size_t i = 0; i < len; ++i) { + s[i] = encoded[i]; + } + } +} + +std::size_t truncate_offset(std::string_view s, std::size_t max_bytes) { + if (max_bytes >= s.size()) { + return s.size(); + } + std::size_t pos = 0; + while (true) { + char32_t cp = 0; + const std::size_t len = utf8::decode(s, pos, cp); + if (len == 0 || pos + len > max_bytes) { + break; + } + pos += len; + } + return pos; +} + +namespace { + +// Local copy of the lead-byte length table. utf8::sequence_length lives in another translation +// unit, and this runs once per character in every scan -- a cross-module call there costs more +// than the work itself. +inline std::size_t lead_len(unsigned char c) { + if (c < 0x80) { + return 1; + } + if (c >= 0xC0 && c <= 0xDF) { + return 2; + } + if (c >= 0xE0 && c <= 0xEF) { + return 3; + } + if (c >= 0xF0 && c <= 0xF7) { + return 4; + } + return 1; +} + +} // namespace + +std::size_t char_bytes(const char *s) { + const unsigned char lead = static_cast(*s); + if (lead < 0x80) { + return 1; + } + const std::size_t want = lead_len(lead); + std::size_t n = 1; + while (n < want && (static_cast(s[n]) & 0xC0) == 0x80) { + ++n; + } + return n; +} + +namespace { + +// Shared driver for the two case-insensitive comparisons. `limit` caps how many bytes of `a` may +// be consumed (npos = unlimited): once that budget is spent the strings count as equal, which is +int compare_folded(std::string_view a, std::string_view b, std::size_t limit) { + std::size_t pa = 0; + std::size_t pb = 0; + while (true) { + if (limit != std::string_view::npos && pa >= limit) { + return 0; + } + char32_t ca = 0; + char32_t cb = 0; + const std::size_t la = utf8::decode(a, pa, ca); + const std::size_t lb = utf8::decode(b, pb, cb); + if (la == 0 && lb == 0) { + return 0; + } + if (la == 0) { + return -1; + } + if (lb == 0) { + return 1; + } + const char32_t fa = utf8::to_lower(ca); + const char32_t fb = utf8::to_lower(cb); + if (fa != fb) { + return fa < fb ? -1 : 1; + } + pa += la; + pb += lb; + } +} + +} // namespace + +int compare_ci(std::string_view a, std::string_view b) { + return compare_folded(a, b, std::string_view::npos); +} + + +bool is_alnum_char(const char *s) { + const unsigned char lead = static_cast(*s); + if (lead < 0x80) { + return (lead >= '0' && lead <= '9') || (lead >= 'A' && lead <= 'Z') || (lead >= 'a' && lead <= 'z'); + } + char32_t cp = 0; + if (utf8::decode(std::string_view(s, char_bytes(s)), 0, cp) == 0) { + return false; + } + // Russian Cyrillic block, including Yo. + return (cp >= 0x0410 && cp <= 0x044F) || cp == 0x0401 || cp == 0x0451; +} + +bool is_alpha_char(const char *s) { + const unsigned char lead = static_cast(*s); + if (lead < 0x80) { + return (lead >= 'A' && lead <= 'Z') || (lead >= 'a' && lead <= 'z'); + } + char32_t cp = 0; + if (utf8::decode(std::string_view(s, char_bytes(s)), 0, cp) == 0) { + return false; + } + return (cp >= 0x0410 && cp <= 0x044F) || cp == 0x0401 || cp == 0x0451; +} + +bool is_upper_char(const char *s) { + const unsigned char lead = static_cast(*s); + if (lead < 0x80) { + return lead >= 'A' && lead <= 'Z'; + } + char32_t cp = 0; + if (utf8::decode(std::string_view(s, char_bytes(s)), 0, cp) == 0) { + return false; + } + return (cp >= 0x0410 && cp <= 0x042F) || cp == 0x0401; +} + +bool chars_equal_ci(const char *a, const char *b) { + char32_t ca = 0; + char32_t cb = 0; + if (utf8::decode(std::string_view(a, char_bytes(a)), 0, ca) == 0 + || utf8::decode(std::string_view(b, char_bytes(b)), 0, cb) == 0) { + return false; + } + return utf8::to_lower(ca) == utf8::to_lower(cb); +} + +namespace { + +// Case folding for the repertoire the engine actually carries -- ASCII and the two-byte Cyrillic +// block -- done directly on the bytes. This runs per character in hot paths (whole-string case +// conversion, argument parsing), so it must not decode, allocate, or make a cross-module call: +// +// A-Z / a-z : one byte, +-0x20 +// A-P (D0 90..D0 9F) <-> a-p (D0 B0..D0 BF) : lead stays D0, trail +-0x20 +// R-Ya(D0 A0..D0 AF) <-> r-ya(D1 80..D1 8F) : lead flips D0<->D1, trail -+0x20 +// Yo (D0 81) <-> yo (D1 91) +// +// Anything else (other scripts, malformed bytes) falls through to the general path below, which +// is correct but slower -- and effectively never taken by this codebase. +inline bool fold_fast(const char *src, char *dst, std::size_t len, bool upper) { + const unsigned char c0 = static_cast(src[0]); + if (c0 < 0x80) { + char c = src[0]; + if (upper) { + if (c >= 'a' && c <= 'z') { + c = static_cast(c - 0x20); + } + } else if (c >= 'A' && c <= 'Z') { + c = static_cast(c + 0x20); + } + dst[0] = c; + return true; + } + if (len != 2) { + return false; + } + const unsigned char c1 = static_cast(src[1]); + unsigned char o0 = c0; + unsigned char o1 = c1; + if (upper) { + if (c0 == 0xD0 && c1 >= 0xB0 && c1 <= 0xBF) { // a-p -> A-P + o1 = static_cast(c1 - 0x20); + } else if (c0 == 0xD1 && c1 >= 0x80 && c1 <= 0x8F) { // r-ya -> R-Ya + o0 = 0xD0; + o1 = static_cast(c1 + 0x20); + } else if (c0 == 0xD1 && c1 == 0x91) { // yo -> Yo + o0 = 0xD0; + o1 = 0x81; + } else if (!((c0 == 0xD0 && c1 >= 0x90 && c1 <= 0xAF) || (c0 == 0xD0 && c1 == 0x81))) { + return false; // not Cyrillic: general path + } + } else { + if (c0 == 0xD0 && c1 >= 0x90 && c1 <= 0x9F) { // A-P -> a-p + o1 = static_cast(c1 + 0x20); + } else if (c0 == 0xD0 && c1 >= 0xA0 && c1 <= 0xAF) { // R-Ya -> r-ya + o0 = 0xD1; + o1 = static_cast(c1 - 0x20); + } else if (c0 == 0xD0 && c1 == 0x81) { // Yo -> yo + o0 = 0xD1; + o1 = 0x91; + } else if (!((c0 == 0xD0 && c1 >= 0xB0) || (c0 == 0xD1 && c1 <= 0x8F) || (c0 == 0xD1 && c1 == 0x91))) { + return false; + } + } + dst[0] = static_cast(o0); + dst[1] = static_cast(o1); + return true; +} + +std::size_t copy_folded_char(const char *src, char *dst, bool upper) { + const std::size_t len = char_bytes(src); + if (fold_fast(src, dst, len, upper)) { + return len; + } + // General path: decode, fold, re-encode; only taken for characters outside ASCII+Cyrillic. + char32_t cp = 0; + if (utf8::decode(std::string_view(src, len), 0, cp) != 0) { + const char32_t folded = upper ? utf8::to_upper(cp) : utf8::to_lower(cp); + char tmp[4]; + if (folded != cp && utf8::encode(folded, tmp) == len) { + for (std::size_t i = 0; i < len; ++i) { + dst[i] = tmp[i]; + } + return len; + } + } + if (dst != src) { + for (std::size_t i = 0; i < len; ++i) { + dst[i] = src[i]; + } + } + return len; +} + +} // namespace + +std::size_t copy_lower_char(const char *src, char *dst) { + return copy_folded_char(src, dst, false); +} + +std::size_t copy_upper_char(const char *src, char *dst) { + return copy_folded_char(src, dst, true); +} + + +char32_t first_char_code(const char *s) { + if (s == nullptr || *s == '\0') { + return 0; + } + char32_t cp = 0; + utf8::decode(std::string_view(s, char_bytes(s)), 0, cp); + return cp; +} + +char32_t first_char_code_lower(const char *s) { + return utf8::to_lower(first_char_code(s)); +} + +char32_t first_char_code_upper(const char *s) { + return utf8::to_upper(first_char_code(s)); +} + +namespace { + +// Whole-buffer case conversion as one tight loop with no calls in the hot path: ASCII and the +// two-byte Cyrillic block are folded straight on the bytes. Anything else falls back to the +// general helper, which this codebase never hits in practice. Written this way deliberately -- +// a per-character dispatch measured several times slower than the byte loop it replaces. +inline void fold_range_utf8(char *p, char *const end, bool upper) { + while (p < end) { + const unsigned char c0 = static_cast(*p); + if (c0 < 0x80) { + char c = *p; + if (upper) { + if (c >= 'a' && c <= 'z') { + c = static_cast(c - 0x20); + } + } else if (c >= 'A' && c <= 'Z') { + c = static_cast(c + 0x20); + } + *p++ = c; + continue; + } + if ((c0 == 0xD0 || c0 == 0xD1) && p + 1 < end) { + const unsigned char c1 = static_cast(p[1]); + if (upper) { + if (c0 == 0xD0 && c1 >= 0xB0) { + p[1] = static_cast(c1 - 0x20); + } else if (c0 == 0xD1 && c1 <= 0x8F) { + p[0] = static_cast(0xD0); + p[1] = static_cast(c1 + 0x20); + } else if (c0 == 0xD1 && c1 == 0x91) { + p[0] = static_cast(0xD0); + p[1] = static_cast(0x81); + } + } else { + if (c0 == 0xD0 && c1 >= 0x90 && c1 <= 0x9F) { + p[1] = static_cast(c1 + 0x20); + } else if (c0 == 0xD0 && c1 >= 0xA0 && c1 <= 0xAF) { + p[0] = static_cast(0xD1); + p[1] = static_cast(c1 - 0x20); + } else if (c0 == 0xD0 && c1 == 0x81) { + p[0] = static_cast(0xD1); + p[1] = static_cast(0x91); + } + } + p += 2; + continue; + } + p += upper ? copy_upper_char(p, p) : copy_lower_char(p, p); + } +} + +} // namespace + +void to_lower(std::string &s) { fold_range_utf8(s.data(), s.data() + s.size(), false); } +void to_upper(std::string &s) { fold_range_utf8(s.data(), s.data() + s.size(), true); } +void to_lower(char *s) { fold_range_utf8(s, s + std::char_traits::length(s), false); } +void to_upper(char *s) { fold_range_utf8(s, s + std::char_traits::length(s), true); } + + +std::string from_koi8(const std::string &text) { + // This runs per attribute/field while the world and the configs load, and the overwhelming + // majority of those are pure ASCII (keys, aliases, numbers), which KOI8-R and UTF-8 spell + // identically. Detect that first and hand the text back untouched instead of transcoding. + bool has_high_byte = false; + for (const char c : text) { + if (static_cast(c) >= 0x80) { + has_high_byte = true; + break; + } + } + if (!has_high_byte) { + return text; + } + // Every KOI8-R character lives below U+FFFF, so three bytes per input byte is a hard bound. + std::vector out(text.size() * 3 + 1, '\0'); + codepages::koi_to_utf8(const_cast(text.c_str()), out.data()); + return std::string(out.data()); +} + +std::string to_koi8(const std::string &text) { + if (text.empty()) { + return text; + } + bool has_high_byte = false; + for (const char c : text) { + if (static_cast(c) >= 0x80) { + has_high_byte = true; + break; + } + } + if (!has_high_byte) { + return text; // pure ASCII is spelled identically in both encodings + } + return codepages::Utf8ToKoi8(text); +} + +std::string from_utf8(const std::string &text) { + return text; // the native encoding already is UTF-8 +} + +std::string translit_to_filename(std::string_view name) { + // Code point -> the very same Latin character the KOI8-R byte table yields, so a player's + // file name is identical before and after the flip. Upper and lower case collapse together + // because the byte-wise original lowercased after transliterating. + static const struct { char32_t cp; char latin; } kMap[] = { + {0x0430, 'a'}, {0x0410, 'a'}, + {0x0431, 'b'}, {0x0411, 'b'}, + {0x0432, 'v'}, {0x0412, 'v'}, + {0x0433, 'g'}, {0x0413, 'g'}, + {0x0434, 'd'}, {0x0414, 'd'}, + {0x0435, 'e'}, {0x0415, 'e'}, + {0x0451, '9'}, {0x0401, '9'}, + {0x0436, '1'}, {0x0416, '1'}, + {0x0437, 'z'}, {0x0417, 'z'}, + {0x0438, 'i'}, {0x0418, 'i'}, + {0x0439, 'j'}, {0x0419, 'j'}, + {0x043A, 'k'}, {0x041A, 'k'}, + {0x043B, 'l'}, {0x041B, 'l'}, + {0x043C, 'm'}, {0x041C, 'm'}, + {0x043D, 'n'}, {0x041D, 'n'}, + {0x043E, 'o'}, {0x041E, 'o'}, + {0x043F, 'p'}, {0x041F, 'p'}, + {0x0440, 'r'}, {0x0420, 'r'}, + {0x0441, 's'}, {0x0421, 's'}, + {0x0442, 't'}, {0x0422, 't'}, + {0x0443, 'y'}, {0x0423, 'y'}, + {0x0444, 'f'}, {0x0424, 'f'}, + {0x0445, 'h'}, {0x0425, 'h'}, + {0x0446, 'c'}, {0x0426, 'c'}, + {0x0447, '7'}, {0x0427, '7'}, + {0x0448, '4'}, {0x0428, '4'}, + {0x0449, '6'}, {0x0429, '6'}, + {0x044A, '8'}, {0x042A, '8'}, + {0x044B, '3'}, {0x042B, '3'}, + {0x044C, '2'}, {0x042C, '2'}, + {0x044D, '5'}, {0x042D, '5'}, + {0x044E, '0'}, {0x042E, '0'}, + {0x044F, 'q'}, {0x042F, 'q'}, + }; + std::string out; + out.reserve(name.size()); + std::size_t pos = 0; + while (pos < name.size()) { + char32_t cp = 0; + const std::size_t len = utf8::decode(name, pos, cp); // decode() already reports the length + if (len == 0) { + break; + } + if (cp < 0x80) { + char c = static_cast(cp); + if (c >= 'A' && c <= 'Z') { + c = static_cast(c + 0x20); + } + out.push_back(c); + } else { + char mapped = '_'; + for (const auto &e : kMap) { + if (e.cp == cp) { + mapped = e.latin; + break; + } + } + out.push_back(mapped); + } + pos += len; + } + return out; +} + + +// --------------------------------------------------------------------------------------------- +// Encoding-independent helpers: expressed purely in terms of the primitives above, so they need +// no per-encoding branch. Unlike char_bytes() these take a bounded view, not a C string, so they +// are safe on a string_view that is not null-terminated. +// --------------------------------------------------------------------------------------------- + +namespace { + +inline std::size_t lead_len_shared(unsigned char c) { + if (c < 0x80) { + return 1; + } + if (c >= 0xC0 && c <= 0xDF) { + return 2; + } + if (c >= 0xE0 && c <= 0xEF) { + return 3; + } + if (c >= 0xF0 && c <= 0xF7) { + return 4; + } + return 1; +} + +// Byte length of the character at `pos`, clamped to the end of `s`. +std::size_t char_bytes_at(std::string_view s, std::size_t pos) { + const unsigned char lead = static_cast(s[pos]); + if (lead < 0x80) { + return 1; + } + const std::size_t want = lead_len_shared(lead); + std::size_t n = 1; + while (n < want && pos + n < s.size() && (static_cast(s[pos + n]) & 0xC0) == 0x80) { + ++n; + } + return n; +} + +} // namespace + +void capitalize_first(std::string &s) { + if (s.empty()) { + return; + } + // The uppercase form keeps the byte length for ASCII and the whole Russian alphabet, so + // capitalising in place never resizes the string. + capitalize_first(&s[0]); +} + +std::size_t CharRange::Iterator::step(std::string_view s, std::size_t pos) { + return pos < s.size() ? char_bytes_at(s, pos) : 0; +} + +std::size_t last_char_offset(std::string_view s) { + std::size_t last = 0; + std::size_t pos = 0; + while (pos < s.size()) { + last = pos; + pos += char_bytes_at(s, pos); + } + return last; +} + +bool list_contains_char(std::string_view list, std::string_view ch) { + if (ch.empty()) { + return false; + } + std::size_t pos = 0; + while (pos < list.size()) { + const std::size_t len = char_bytes_at(list, pos); + if (len == ch.size() && list.compare(pos, len, ch) == 0) { + return true; + } + pos += len; + } + return false; +} + +std::string from_disk_line(const char *line) { + if (line == nullptr || *line == '\0') { + return {}; + } + const std::string_view view(line); + return utf8::is_valid(view) ? std::string(view) : from_koi8(std::string(view)); +} + +std::string from_disk_text(const std::string &text) { + // Same discriminator as from_disk_line, applied to the whole file: well-formed UTF-8 is taken + // as already native, anything else as KOI8-R. Cyrillic in KOI8-R is almost never valid UTF-8, + // which makes validity a reliable test, and it is what keeps a load/save cycle idempotent. + return utf8::is_valid(text) ? text : from_koi8(text); +} + +std::size_t char_offset(std::string_view s, std::size_t chars) { + return utf8::byte_offset(s, chars); +} + +bool write_file(const std::string &path, const std::string &text) { + const std::string on_disk = to_disk(text); + std::ofstream out(path, std::ios::binary); + if (!out) { + return false; + } + out.write(on_disk.data(), static_cast(on_disk.size())); + return out.good(); +} + +std::string pad_right(std::string_view s, std::size_t width) { + const std::size_t len = char_count(s); + std::string out(s); + if (len < width) { + out.append(width - len, ' '); + } + return out; +} + +std::string to_disk(const std::string &text) { + // Предохранитель. Всё нативное -- валидный UTF-8; если сюда пришло что-то другое, значит + // строка не проходила границу чтения и держит дисковые байты (KOI8-R) как есть. + // Транслитерировать их нельзя: to_koi8 разберёт такие байты как Latin-1 и прогонит через + // словарь замен, а это необратимо -- 'верий.свет' превращается в 'AIEUAxAOA.OxAO'. Именно + // так были съедены метки вещей, сундуки дружин и списки имён (issue #3681). + // + // Поэтому пишем байты как есть -- для диска они уже в нужной кодировке, файл остаётся цел, -- + // и жалуемся в лог: дыру видно сразу, без нагрузочного прогона и без потери данных. + if (!utf8::is_valid(text)) { + static std::atomic seen{0}; + const unsigned long n = seen.fetch_add(1); + if (n < 10 || n % 10000 == 0) { + // Байты печатаются шестнадцатеричными нарочно: сунуть их в сообщение как есть + // значило бы отдать логгеру невалидный UTF-8, а он пишет через этот же to_disk -- + // и жалоба принялась бы жаловаться сама на себя без конца. + std::string head; + const std::size_t show = std::min(text.size(), 16); + char byte[4]; + for (std::size_t i = 0; i < show; ++i) { + std::snprintf(byte, sizeof(byte), "%02x", static_cast(text[i])); + head += byte; + head += ' '; + } + log("SYSERR: to_disk got non-UTF-8 text (#%lu, %zu bytes) -- a read boundary is missing " + "somewhere; writing the bytes through unchanged. First bytes: %s", + n + 1, text.size(), head.c_str()); + } + return text; + } + return to_koi8(text); +} + +std::string read_data_file(const std::string &path) { + std::ifstream in(path, std::ios::binary); + if (!in) { + return {}; + } + std::string raw((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + return from_disk_text(raw); +} + + +std::string sort_key(const std::string &text) { + std::string key = to_koi8(text); + for (char &c : key) { + c = codepages::KtoW(c); + } + return key; +} + +} // namespace native_text + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/utils/native_text.h b/src/utils/native_text.h new file mode 100644 index 0000000000..9fa3a69b40 --- /dev/null +++ b/src/utils/native_text.h @@ -0,0 +1,229 @@ +/** +\file native_text.h - a part of the Bylins engine. +\brief Character-semantic operations in the engine's *native runtime encoding* (issue #3681). + +The engine holds text in UTF-8, where one character is one to four bytes. Code that reasons about +characters -- counting width, capitalising a letter, truncating without splitting a character, +comparing case-insensitively -- goes through here instead of touching bytes directly: a plain +LOWER(*s) or s[0] = UPPER(s[0]) is wrong on a multibyte letter and was the single largest source +of bugs in the migration. + +The conversions at the bottom of this header are boundaries, not helpers for everyday code: +from_disk_* / to_disk for the world files (still KOI8-R on disk), to_koi8 for legacy client code +pages (their tables are indexed by KOI8-R bytes). +*/ + +#ifndef BYLINS_SRC_UTILS_NATIVE_TEXT_H_ +#define BYLINS_SRC_UTILS_NATIVE_TEXT_H_ + +#include +#include +#include + +namespace native_text { + + +// Number of display characters in a byte range / view. KOI8-R: the byte count. UTF-8: the number +// of code points (malformed bytes counted as one each, so it never stalls on legacy data). +std::size_t char_count(const char *begin, const char *end); +std::size_t char_count(std::string_view s); + +// Uppercase the first character of the null-terminated string `s` in place (ASCII + Russian +// Cyrillic incl. Yo). No-op on an empty string, on a non-cased first character, or in the (never +// occurring for these alphabets) case where the uppercase form has a different byte length. +void capitalize_first(char *s); +void capitalize_first(std::string &s); + +// Largest byte offset <= max_bytes that lands on a character boundary, so cutting the string +// there never splits a multibyte character. KOI8-R: min(max_bytes, s.size()). +std::size_t truncate_offset(std::string_view s, std::size_t max_bytes); + +// Byte offset of the `chars`-th character (clamped to the end), so `s.substr(0, char_offset(s, n))` +// keeps exactly n characters. KOI8-R: min(chars, s.size()). Use this, not substr(0, n), wherever +// text is cut to fit a column: cutting by bytes both halves the visible width under UTF-8 and can +// split a character in two (issue #3681). +std::size_t char_offset(std::string_view s, std::size_t chars); + +// Byte length of the character that starts at `s` (KOI8-R: 1), for stepping over a whole +// character byte-by-byte. Always >= 1; on a malformed/truncated UTF-8 lead it returns only the +// bytes actually present (never counts past a terminator or a non-continuation byte). +std::size_t char_bytes(const char *s); + +// Numeric identity of the character starting at `s`, for dispatching a switch on a letter: +// the raw byte under KOI8-R, the code point under UTF-8. Compare against the constants in +// utils/russian_keys.h (Cyrillic) or ordinary character literals (ASCII, identical in both). +// Returns 0 on an empty string. +char32_t first_char_code(const char *s); + +// The same, case-folded -- the replacements for switch (LOWER(*s)) / switch (UPPER(*s)). +char32_t first_char_code_lower(const char *s); +char32_t first_char_code_upper(const char *s); + +// Case-insensitive comparison in the native encoding: lexicographic over lowered characters, +// the shorter string orders first, returns the signed difference at the first mismatch (0 when +// equal). KOI8-R: per byte, via LOWER() -- matches str_cmp/str/str semantics. UTF-8: per code +// point, folded via utf8::to_lower (so the sign is meaningful; the magnitude is a code-point +// difference). +int compare_ci(std::string_view a, std::string_view b); + +// Is the character starting at `s` alphanumeric? KOI8-R: the a_isalnum byte table. UTF-8: ASCII +// letters/digits plus the Russian Cyrillic block -- so a multibyte letter is classified as one +// alphanumeric character rather than a lead byte followed by "punctuation" trail bytes. +bool is_alnum_char(const char *s); + +// Is the character starting at `s` a letter? Same contract as is_alnum_char, minus the digits. +bool is_alpha_char(const char *s); + +// Is the character starting at `s` an uppercase letter? KOI8-R: the a_isupper byte table. +// UTF-8: ASCII A-Z plus the uppercase Cyrillic range (and Yo) as whole code points -- the byte +// table cannot see these at all, since a UTF-8 Cyrillic lead byte is not in its uppercase range. +bool is_upper_char(const char *s); + +// Do the characters starting at `a` and `b` match ignoring case? KOI8-R: LOWER(*a) == LOWER(*b). +// UTF-8: compares whole folded code points, so "P" matches "p" in Cyrillic too. +bool chars_equal_ci(const char *a, const char *b); + +// Copy the character starting at `src` to `dst`, lowercased, and return how many bytes were +// consumed (always the same number written, so a caller's buffer accounting is unaffected). +// KOI8-R: one byte through a_lcc_table. UTF-8: folds the code point; in the rare case where the +// lowercase form would not be the same byte length, the character is copied unchanged rather +// than resized. `dst` may alias `src` (the length-preserving property makes that safe). +std::size_t copy_lower_char(const char *src, char *dst); + +// Uppercase counterpart of copy_lower_char, with the same contract. +std::size_t copy_upper_char(const char *src, char *dst); + +// Byte offset at which the final character of `s` begins (0 for an empty string), so that +// s.substr(0, last_char_offset(s)) drops exactly one character and s.substr(last_char_offset(s)) +// is that character. KOI8-R: s.size() - 1. +std::size_t last_char_offset(std::string_view s); + +// Range-for over the characters of `s`: each element is a string_view covering exactly one +// character, so scanning code never does pointer arithmetic and never lands mid-character. +// +// for (auto ch : native_text::chars(name)) { ... } // ch is one character, whatever its size +// +// The view must outlive the loop (it is not copied). Malformed bytes yield one element each. +class CharRange { + public: + explicit CharRange(std::string_view s) : m_str(s) {} + + class Iterator { + public: + Iterator(std::string_view s, std::size_t pos) : m_str(s), m_pos(pos), m_len(step(s, pos)) {} + std::string_view operator*() const { return m_str.substr(m_pos, m_len); } + Iterator &operator++() { + m_pos += m_len; + m_len = step(m_str, m_pos); + return *this; + } + bool operator!=(const Iterator &other) const { return m_pos != other.m_pos; } + + private: + static std::size_t step(std::string_view s, std::size_t pos); + std::string_view m_str; + std::size_t m_pos; + std::size_t m_len; + }; + + [[nodiscard]] Iterator begin() const { return Iterator(m_str, 0); } + [[nodiscard]] Iterator end() const { return Iterator(m_str, m_str.size()); } + + private: + std::string_view m_str; +}; + +inline CharRange chars(std::string_view s) { return CharRange(s); } + +// Whole-string case conversion in place. Prefer these over hand-rolled per-character loops. +// Length-preserving for ASCII and the Russian alphabet, so no reallocation happens. +void to_lower(std::string &s); +void to_upper(std::string &s); +void to_lower(char *s); +void to_upper(char *s); + +// Bring text stored on disk in KOI8-R (world files, configs, saves) into the engine's native +// encoding. Identity under KOI8-R, a transcode under UTF-8. Having it here keeps the loaders +// free of #ifdefs and gives one place to revisit when the data files themselves move. +std::string from_koi8(const std::string &text); + +// The inverse: take native text down to KOI8-R. Needed wherever something downstream is defined +// in terms of KOI8-R bytes -- the legacy client code pages are KOI8-R -> target byte tables, and +// the on-disk formats are KOI8-R. Identity under KOI8-R. A character KOI8-R does not have is +// first reduced to the closest one it does (see translit_koi8.h): a typographic dash to "-", a +// rounded frame corner to a square one, an accented letter to its base. Only what has no +// equivalent at all becomes the converter's placeholder. +std::string to_koi8(const std::string &text); + +// Bring text that is UTF-8 on disk into the native encoding. The counterpart of from_koi8 for +// the files that are deliberately kept in UTF-8 rather than KOI8-R (the login screen). Identity +// under UTF-8; under KOI8-R it goes through the same reduction as to_koi8, so a file written +// with the full Unicode repertoire still renders sensibly on a KOI8-R build. +std::string from_utf8(const std::string &text); + +// Collation key for sorting Russian text. The Russian letters are not in alphabetical order in +// KOI8-R, so sorting has always gone through Windows-1251 bytes, where they are. The key +// reproduces exactly that order and does not depend on the native encoding: compare two keys +// instead of transcoding the strings themselves (which corrupted them under UTF-8, since the +// KOI8-R -> Windows-1251 byte table means nothing for a multibyte string). +std::string sort_key(const std::string &text); + +// Read a data file (all of which are stored in KOI8-R) and hand back its contents in the +// engine's native encoding. The counterpart of from_koi8 for whole files: parsers that take a +// buffer should go through this instead of reading the path themselves, so the boundary stays +// in one place. Returns an empty string if the file cannot be read -- callers report that the +// same way they did when the parser failed to open it. +std::string read_data_file(const std::string &path); + +// Bring one line read from a data file into the native encoding, tolerating a file that has +// already been converted. Under KOI8-R this is the identity. Under UTF-8 the text is taken as +// already-native when it is well-formed UTF-8 and transcoded otherwise -- Cyrillic in KOI8-R is +// almost never valid UTF-8, which makes validity a reliable discriminator and lets old and new +// player files coexist without a version field. +std::string from_disk_line(const char *line); + +// The same for a whole file's contents. Use this, not from_koi8, for anything the engine also +// WRITES back: from_koi8 transcodes unconditionally, so a file the engine already saved in the +// native encoding would be transcoded a second time -- and since the save then writes that back, +// every Cyrillic byte doubles on each load/save cycle and the file grows exponentially (that is +// exactly what happened to cfg/mechanics/obj_sets.xml, issue #3681). +std::string from_disk_text(const std::string &text); + +// The write side of the same boundary, and the exact mirror of from_disk_text: whatever the +// engine puts on disk goes out in the encoding the disk format is in, which during the migration +// is still KOI8-R. Identity under KOI8-R; under UTF-8 the text is reduced and transcoded exactly +// as it is for a legacy client (see to_koi8). +// +// Read and write MUST stay symmetric. If the engine writes the native encoding while the rest of +// the world is KOI8-R, then the first save quietly converts every file it touches, rolling back +// to a KOI8-R build stops being possible, and the world is no longer the world we started with. +// (issue #3681). +std::string to_disk(const std::string &text); + +// Записать текст в файл в кодировке мира. Однострочная обёртка над to_disk для тех, кто иначе +// звал бы pugi::save_file или свой ofstream и уносил бы на диск нативную кодировку. Возвращает +// false, если файл не открылся (issue #3681). +bool write_file(const std::string &path, const std::string &text); + +// Pad `s` on the right with spaces to `width` CHARACTERS. The replacement for printf's "%-Ns" +// wherever the value can hold Russian: printf counts the field width in bytes, so under UTF-8 a +// Cyrillic word ate twice its share and the column drifted. Under KOI8-R this is byte-for-byte +// what "%-Ns" did. Longer input is returned untouched, exactly like printf (issue #3681). +std::string pad_right(std::string_view s, std::size_t width); + +// Transliterate `name` into the ASCII form used for save-file names: Russian letters become +// Latin ones, ASCII is lowercased. The mapping is fixed by what the byte-wise implementation +// produced before the migration and MUST NOT drift -- the result is the on-disk file name of a +// player, so a change would orphan every existing character. Pinned by a test in both encodings. +std::string translit_to_filename(std::string_view name); + +// Does the single character `ch` occur in `list`? The replacement for strchr() over a literal +// list of letters: `list` is walked one whole character at a time, so a multibyte character can +// never match on a partial byte sequence. Comparison is exact (case-sensitive), like strchr. +bool list_contains_char(std::string_view list, std::string_view ch); + +} // namespace native_text + +#endif // BYLINS_SRC_UTILS_NATIVE_TEXT_H_ + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/utils/parser_wrapper.cpp b/src/utils/parser_wrapper.cpp index 972e85d16c..1aaf3661ee 100644 --- a/src/utils/parser_wrapper.cpp +++ b/src/utils/parser_wrapper.cpp @@ -1,4 +1,6 @@ #include "parser_wrapper.h" +#include +#include "utils/native_text.h" #include "utils/logger.h" @@ -20,7 +22,15 @@ DataNode::DataNode() : DataNode::DataNode(const std::filesystem::path &file_name) : DataNode() { - if (auto result = impl_->xml_doc->load_file(file_name.c_str()); !result) { + // Содержимое приводится к нативной кодировке движка ОДИН раз на документ, а не на каждое + // поле: тогда всё, что читается через DataNode, приходит уже в нужной кодировке. Под KOI8-R + // это тождество (issue #3681). + // + // read_data_file, а не самодельное чтение с from_koi8: конфиг движок не только читает, но и + // пишет обратно, а безусловная перекодировка уже сохранённого в UTF-8 файла удваивала бы + // каждую кириллическую букву на каждом цикле загрузки-сохранения. + const std::string converted = native_text::read_data_file(file_name.string()); + if (auto result = impl_->xml_doc->load_buffer(converted.data(), converted.size()); !result) { std::ostringstream buffer; buffer << "..." << result.description() << "\r\n" << " (file: " << file_name << ")" << "\r\n"; err_log("%s", buffer.str().c_str()); @@ -112,7 +122,18 @@ bool DataNode::Save(const std::filesystem::path &file) const { decl.append_attribute("encoding"); } decl.attribute("encoding").set_value("koi8-r"); - return doc.save_file(file.string().c_str()); + // Пишем в той же кодировке, в какой файл лежит на диске (сейчас KOI8-R), а не в нативной: + // граница записи обязана быть зеркалом границы чтения, иначе первое же сохранение молча + // переводит файл в UTF-8 и откат на KOI8-R-сборку становится невозможен (issue #3681). + std::ostringstream xml; + doc.save(xml, "\t", pugi::format_default, pugi::encoding_utf8); + const std::string on_disk = native_text::to_disk(xml.str()); + std::ofstream out(file, std::ios::binary); + if (!out) { + return false; + } + out.write(on_disk.data(), static_cast(on_disk.size())); + return out.good(); } std::string DataNode::ToXmlString() const { diff --git a/src/utils/russian_keys.h b/src/utils/russian_keys.h new file mode 100644 index 0000000000..86cef48523 --- /dev/null +++ b/src/utils/russian_keys.h @@ -0,0 +1,79 @@ +/** +\file russian_keys.h - a part of the Bylins engine. +\brief Russian letters as switch-able constants, valid in both native encodings (issue #3681). + +Menus and OLC editors dispatch on a single letter the player typed: + + switch (*arg) { + case 'y': case 'Y': case 'd': case 'D': ... + +A Cyrillic *character* literal cannot survive the encoding flip: with UTF-8 sources 'd' (Cyrillic) +is a multi-character constant, so the compiler folds it to an implementation-defined value and the +menu silently stops responding. Nor can such a literal be written portably for both encodings. + +The way out is to keep the switch but dispatch on a number instead of a literal. Under KOI8-R a +letter is one byte, under UTF-8 it is a code point, so each letter is spelled out numerically for +both and the constants below are what the switch compares against: + + switch (native_text::first_char_code(arg)) { + case 'y': case 'Y': case rus::kDa: case rus::kDaUpper: ... + +Note that ASCII cases stay ordinary character literals -- those are identical in both encodings. + +Once the flip is permanent (track D) the KOI8-R half goes away and these can collapse into plain +U'...' literals. +*/ + +#ifndef BYLINS_SRC_UTILS_RUSSIAN_KEYS_H_ +#define BYLINS_SRC_UTILS_RUSSIAN_KEYS_H_ + +namespace rus { + + +// Unicode code points: U+0410..U+042F (upper), U+0430..U+044F (lower), U+0401/U+0451 (Yo). +#define BYLINS_RUS_LETTER(name, upper_cp, lower_cp) \ + constexpr char32_t name##Upper = upper_cp; \ + constexpr char32_t name = lower_cp + + +BYLINS_RUS_LETTER(kA, 0x0410, 0x0430); // А а +BYLINS_RUS_LETTER(kBe, 0x0411, 0x0431); // Б б +BYLINS_RUS_LETTER(kVe, 0x0412, 0x0432); // В в +BYLINS_RUS_LETTER(kGe, 0x0413, 0x0433); // Г г +BYLINS_RUS_LETTER(kDe, 0x0414, 0x0434); // Д д +BYLINS_RUS_LETTER(kIe, 0x0415, 0x0435); // Е е +BYLINS_RUS_LETTER(kYo, 0x0401, 0x0451); // Ё ё +BYLINS_RUS_LETTER(kZhe, 0x0416, 0x0436); // Ж ж +BYLINS_RUS_LETTER(kZe, 0x0417, 0x0437); // З з +BYLINS_RUS_LETTER(kI, 0x0418, 0x0438); // И и +BYLINS_RUS_LETTER(kIi, 0x0419, 0x0439); // Й й +BYLINS_RUS_LETTER(kKa, 0x041A, 0x043A); // К к +BYLINS_RUS_LETTER(kEl, 0x041B, 0x043B); // Л л +BYLINS_RUS_LETTER(kEm, 0x041C, 0x043C); // М м +BYLINS_RUS_LETTER(kEn, 0x041D, 0x043D); // Н н +BYLINS_RUS_LETTER(kO, 0x041E, 0x043E); // О о +BYLINS_RUS_LETTER(kPe, 0x041F, 0x043F); // П п +BYLINS_RUS_LETTER(kEr, 0x0420, 0x0440); // Р р +BYLINS_RUS_LETTER(kEs, 0x0421, 0x0441); // С с +BYLINS_RUS_LETTER(kTe, 0x0422, 0x0442); // Т т +BYLINS_RUS_LETTER(kU, 0x0423, 0x0443); // У у +BYLINS_RUS_LETTER(kEf, 0x0424, 0x0444); // Ф ф +BYLINS_RUS_LETTER(kHa, 0x0425, 0x0445); // Х х +BYLINS_RUS_LETTER(kTse, 0x0426, 0x0446); // Ц ц +BYLINS_RUS_LETTER(kChe, 0x0427, 0x0447); // Ч ч +BYLINS_RUS_LETTER(kSha, 0x0428, 0x0448); // Ш ш +BYLINS_RUS_LETTER(kScha,0x0429, 0x0449); // Щ щ +BYLINS_RUS_LETTER(kHard,0x042A, 0x044A); // Ъ ъ +BYLINS_RUS_LETTER(kYery,0x042B, 0x044B); // Ы ы +BYLINS_RUS_LETTER(kSoft,0x042C, 0x044C); // Ь ь +BYLINS_RUS_LETTER(kE, 0x042D, 0x044D); // Э э +BYLINS_RUS_LETTER(kYu, 0x042E, 0x044E); // Ю ю +BYLINS_RUS_LETTER(kYa, 0x042F, 0x044F); // Я я + +#undef BYLINS_RUS_LETTER + +} // namespace rus + +#endif // BYLINS_SRC_UTILS_RUSSIAN_KEYS_H_ + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/utils/tracing/trace_sender.h b/src/utils/tracing/trace_sender.h index 9d9a263090..dd32e37b1e 100644 --- a/src/utils/tracing/trace_sender.h +++ b/src/utils/tracing/trace_sender.h @@ -30,7 +30,7 @@ class ISpan { virtual void End() = 0; virtual void AddEvent(const std::string& name) = 0; - // String values are auto-converted from KOI8-R to UTF-8 -- pass raw KOI8-R, never call koi8r_to_utf8() first. + // String values go out as they are: the engine holds UTF-8 and OTLP wants UTF-8. virtual void SetAttribute(const std::string& key, const std::string& value) = 0; virtual void SetAttribute(const std::string& key, int64_t value) = 0; virtual void SetAttribute(const std::string& key, double value) = 0; diff --git a/src/utils/translit_koi8.cpp b/src/utils/translit_koi8.cpp new file mode 100644 index 0000000000..439ee717f5 --- /dev/null +++ b/src/utils/translit_koi8.cpp @@ -0,0 +1,550 @@ +/** +\file translit_koi8.cpp - a part of the Bylins engine. +\brief Reducing text to what KOI8-R can actually represent (issue #3681). + +Legacy clients speak KOI8-R, CP866 or Windows-1251, and all of those are reached through +KOI8-R. When the engine runs on UTF-8 the text may contain characters none of them has -- a +typographic dash, curly quotes, an accented Latin letter, an emoji. Dropping such a character +to a single placeholder loses information that a reader could still have used, so this table +maps each one to the closest thing KOI8-R does have ("--", '"', "e", "..."), and only what has +no sensible equivalent falls back to the placeholder. + +The table is generated, not written by hand: every entry was checked against the KOI8-R codec +to be genuinely absent from it, and the Latin replacements come from Unicode NFKD with the +combining marks stripped. Replacements are UTF-8, because the substitution happens before the +conversion, and are spelled as \xNN escapes so they do not depend on this file's encoding. +*/ + +#include "translit_koi8.h" + +#include "utf8.h" +#include "utils_encoding.h" + +#include +#include + +namespace codepages { + +namespace { + +struct TranslitEntry { + char32_t code_point; + const char *replacement; // UTF-8 +}; + +// Sorted by code point: looked up with a binary search. +constexpr TranslitEntry kTable[] = { + {0x00A2, "\x63"}, // CENT SIGN + {0x00A3, "\x4C"}, // POUND SIGN + {0x00A5, "\x59"}, // YEN SIGN + {0x00AB, "\x22"}, // LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + {0x00AD, ""}, // SOFT HYPHEN + {0x00AE, "\x52"}, // REGISTERED SIGN + {0x00BB, "\x22"}, // RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + {0x00C0, "\x41"}, // LATIN CAPITAL LETTER A WITH GRAVE + {0x00C1, "\x41"}, // LATIN CAPITAL LETTER A WITH ACUTE + {0x00C2, "\x41"}, // LATIN CAPITAL LETTER A WITH CIRCUMFLEX + {0x00C3, "\x41"}, // LATIN CAPITAL LETTER A WITH TILDE + {0x00C4, "\x41"}, // LATIN CAPITAL LETTER A WITH DIAERESIS + {0x00C5, "\x41"}, // LATIN CAPITAL LETTER A WITH RING ABOVE + {0x00C6, "\x41\x45"}, // LATIN CAPITAL LETTER AE + {0x00C7, "\x43"}, // LATIN CAPITAL LETTER C WITH CEDILLA + {0x00C8, "\x45"}, // LATIN CAPITAL LETTER E WITH GRAVE + {0x00C9, "\x45"}, // LATIN CAPITAL LETTER E WITH ACUTE + {0x00CA, "\x45"}, // LATIN CAPITAL LETTER E WITH CIRCUMFLEX + {0x00CB, "\x45"}, // LATIN CAPITAL LETTER E WITH DIAERESIS + {0x00CC, "\x49"}, // LATIN CAPITAL LETTER I WITH GRAVE + {0x00CD, "\x49"}, // LATIN CAPITAL LETTER I WITH ACUTE + {0x00CE, "\x49"}, // LATIN CAPITAL LETTER I WITH CIRCUMFLEX + {0x00CF, "\x49"}, // LATIN CAPITAL LETTER I WITH DIAERESIS + {0x00D0, "\x44"}, // LATIN CAPITAL LETTER ETH + {0x00D1, "\x4E"}, // LATIN CAPITAL LETTER N WITH TILDE + {0x00D2, "\x4F"}, // LATIN CAPITAL LETTER O WITH GRAVE + {0x00D3, "\x4F"}, // LATIN CAPITAL LETTER O WITH ACUTE + {0x00D4, "\x4F"}, // LATIN CAPITAL LETTER O WITH CIRCUMFLEX + {0x00D5, "\x4F"}, // LATIN CAPITAL LETTER O WITH TILDE + {0x00D6, "\x4F"}, // LATIN CAPITAL LETTER O WITH DIAERESIS + {0x00D7, "\x78"}, // MULTIPLICATION SIGN + {0x00D8, "\x4F"}, // LATIN CAPITAL LETTER O WITH STROKE + {0x00D9, "\x55"}, // LATIN CAPITAL LETTER U WITH GRAVE + {0x00DA, "\x55"}, // LATIN CAPITAL LETTER U WITH ACUTE + {0x00DB, "\x55"}, // LATIN CAPITAL LETTER U WITH CIRCUMFLEX + {0x00DC, "\x55"}, // LATIN CAPITAL LETTER U WITH DIAERESIS + {0x00DD, "\x59"}, // LATIN CAPITAL LETTER Y WITH ACUTE + {0x00DE, "\x54\x68"}, // LATIN CAPITAL LETTER THORN + {0x00DF, "\x73\x73"}, // LATIN SMALL LETTER SHARP S + {0x00E0, "\x61"}, // LATIN SMALL LETTER A WITH GRAVE + {0x00E1, "\x61"}, // LATIN SMALL LETTER A WITH ACUTE + {0x00E2, "\x61"}, // LATIN SMALL LETTER A WITH CIRCUMFLEX + {0x00E3, "\x61"}, // LATIN SMALL LETTER A WITH TILDE + {0x00E4, "\x61"}, // LATIN SMALL LETTER A WITH DIAERESIS + {0x00E5, "\x61"}, // LATIN SMALL LETTER A WITH RING ABOVE + {0x00E6, "\x61\x65"}, // LATIN SMALL LETTER AE + {0x00E7, "\x63"}, // LATIN SMALL LETTER C WITH CEDILLA + {0x00E8, "\x65"}, // LATIN SMALL LETTER E WITH GRAVE + {0x00E9, "\x65"}, // LATIN SMALL LETTER E WITH ACUTE + {0x00EA, "\x65"}, // LATIN SMALL LETTER E WITH CIRCUMFLEX + {0x00EB, "\x65"}, // LATIN SMALL LETTER E WITH DIAERESIS + {0x00EC, "\x69"}, // LATIN SMALL LETTER I WITH GRAVE + {0x00ED, "\x69"}, // LATIN SMALL LETTER I WITH ACUTE + {0x00EE, "\x69"}, // LATIN SMALL LETTER I WITH CIRCUMFLEX + {0x00EF, "\x69"}, // LATIN SMALL LETTER I WITH DIAERESIS + {0x00F0, "\x64"}, // LATIN SMALL LETTER ETH + {0x00F1, "\x6E"}, // LATIN SMALL LETTER N WITH TILDE + {0x00F2, "\x6F"}, // LATIN SMALL LETTER O WITH GRAVE + {0x00F3, "\x6F"}, // LATIN SMALL LETTER O WITH ACUTE + {0x00F4, "\x6F"}, // LATIN SMALL LETTER O WITH CIRCUMFLEX + {0x00F5, "\x6F"}, // LATIN SMALL LETTER O WITH TILDE + {0x00F6, "\x6F"}, // LATIN SMALL LETTER O WITH DIAERESIS + {0x00F8, "\x6F"}, // LATIN SMALL LETTER O WITH STROKE + {0x00F9, "\x75"}, // LATIN SMALL LETTER U WITH GRAVE + {0x00FA, "\x75"}, // LATIN SMALL LETTER U WITH ACUTE + {0x00FB, "\x75"}, // LATIN SMALL LETTER U WITH CIRCUMFLEX + {0x00FC, "\x75"}, // LATIN SMALL LETTER U WITH DIAERESIS + {0x00FD, "\x79"}, // LATIN SMALL LETTER Y WITH ACUTE + {0x00FE, "\x74\x68"}, // LATIN SMALL LETTER THORN + {0x00FF, "\x79"}, // LATIN SMALL LETTER Y WITH DIAERESIS + {0x0100, "\x41"}, // LATIN CAPITAL LETTER A WITH MACRON + {0x0101, "\x61"}, // LATIN SMALL LETTER A WITH MACRON + {0x0102, "\x41"}, // LATIN CAPITAL LETTER A WITH BREVE + {0x0103, "\x61"}, // LATIN SMALL LETTER A WITH BREVE + {0x0104, "\x41"}, // LATIN CAPITAL LETTER A WITH OGONEK + {0x0105, "\x61"}, // LATIN SMALL LETTER A WITH OGONEK + {0x0106, "\x43"}, // LATIN CAPITAL LETTER C WITH ACUTE + {0x0107, "\x63"}, // LATIN SMALL LETTER C WITH ACUTE + {0x0108, "\x43"}, // LATIN CAPITAL LETTER C WITH CIRCUMFLEX + {0x0109, "\x63"}, // LATIN SMALL LETTER C WITH CIRCUMFLEX + {0x010A, "\x43"}, // LATIN CAPITAL LETTER C WITH DOT ABOVE + {0x010B, "\x63"}, // LATIN SMALL LETTER C WITH DOT ABOVE + {0x010C, "\x43"}, // LATIN CAPITAL LETTER C WITH CARON + {0x010D, "\x63"}, // LATIN SMALL LETTER C WITH CARON + {0x010E, "\x44"}, // LATIN CAPITAL LETTER D WITH CARON + {0x010F, "\x64"}, // LATIN SMALL LETTER D WITH CARON + {0x0112, "\x45"}, // LATIN CAPITAL LETTER E WITH MACRON + {0x0113, "\x65"}, // LATIN SMALL LETTER E WITH MACRON + {0x0114, "\x45"}, // LATIN CAPITAL LETTER E WITH BREVE + {0x0115, "\x65"}, // LATIN SMALL LETTER E WITH BREVE + {0x0116, "\x45"}, // LATIN CAPITAL LETTER E WITH DOT ABOVE + {0x0117, "\x65"}, // LATIN SMALL LETTER E WITH DOT ABOVE + {0x0118, "\x45"}, // LATIN CAPITAL LETTER E WITH OGONEK + {0x0119, "\x65"}, // LATIN SMALL LETTER E WITH OGONEK + {0x011A, "\x45"}, // LATIN CAPITAL LETTER E WITH CARON + {0x011B, "\x65"}, // LATIN SMALL LETTER E WITH CARON + {0x011C, "\x47"}, // LATIN CAPITAL LETTER G WITH CIRCUMFLEX + {0x011D, "\x67"}, // LATIN SMALL LETTER G WITH CIRCUMFLEX + {0x011E, "\x47"}, // LATIN CAPITAL LETTER G WITH BREVE + {0x011F, "\x67"}, // LATIN SMALL LETTER G WITH BREVE + {0x0120, "\x47"}, // LATIN CAPITAL LETTER G WITH DOT ABOVE + {0x0121, "\x67"}, // LATIN SMALL LETTER G WITH DOT ABOVE + {0x0122, "\x47"}, // LATIN CAPITAL LETTER G WITH CEDILLA + {0x0123, "\x67"}, // LATIN SMALL LETTER G WITH CEDILLA + {0x0124, "\x48"}, // LATIN CAPITAL LETTER H WITH CIRCUMFLEX + {0x0125, "\x68"}, // LATIN SMALL LETTER H WITH CIRCUMFLEX + {0x0128, "\x49"}, // LATIN CAPITAL LETTER I WITH TILDE + {0x0129, "\x69"}, // LATIN SMALL LETTER I WITH TILDE + {0x012A, "\x49"}, // LATIN CAPITAL LETTER I WITH MACRON + {0x012B, "\x69"}, // LATIN SMALL LETTER I WITH MACRON + {0x012C, "\x49"}, // LATIN CAPITAL LETTER I WITH BREVE + {0x012D, "\x69"}, // LATIN SMALL LETTER I WITH BREVE + {0x012E, "\x49"}, // LATIN CAPITAL LETTER I WITH OGONEK + {0x012F, "\x69"}, // LATIN SMALL LETTER I WITH OGONEK + {0x0130, "\x49"}, // LATIN CAPITAL LETTER I WITH DOT ABOVE + {0x0132, "\x49\x4A"}, // LATIN CAPITAL LIGATURE IJ + {0x0133, "\x69\x6A"}, // LATIN SMALL LIGATURE IJ + {0x0134, "\x4A"}, // LATIN CAPITAL LETTER J WITH CIRCUMFLEX + {0x0135, "\x6A"}, // LATIN SMALL LETTER J WITH CIRCUMFLEX + {0x0136, "\x4B"}, // LATIN CAPITAL LETTER K WITH CEDILLA + {0x0137, "\x6B"}, // LATIN SMALL LETTER K WITH CEDILLA + {0x0139, "\x4C"}, // LATIN CAPITAL LETTER L WITH ACUTE + {0x013A, "\x6C"}, // LATIN SMALL LETTER L WITH ACUTE + {0x013B, "\x4C"}, // LATIN CAPITAL LETTER L WITH CEDILLA + {0x013C, "\x6C"}, // LATIN SMALL LETTER L WITH CEDILLA + {0x013D, "\x4C"}, // LATIN CAPITAL LETTER L WITH CARON + {0x013E, "\x6C"}, // LATIN SMALL LETTER L WITH CARON + {0x013F, "\x4C"}, // LATIN CAPITAL LETTER L WITH MIDDLE DOT + {0x0140, "\x6C"}, // LATIN SMALL LETTER L WITH MIDDLE DOT + {0x0141, "\x4C"}, // LATIN CAPITAL LETTER L WITH STROKE + {0x0142, "\x6C"}, // LATIN SMALL LETTER L WITH STROKE + {0x0143, "\x4E"}, // LATIN CAPITAL LETTER N WITH ACUTE + {0x0144, "\x6E"}, // LATIN SMALL LETTER N WITH ACUTE + {0x0145, "\x4E"}, // LATIN CAPITAL LETTER N WITH CEDILLA + {0x0146, "\x6E"}, // LATIN SMALL LETTER N WITH CEDILLA + {0x0147, "\x4E"}, // LATIN CAPITAL LETTER N WITH CARON + {0x0148, "\x6E"}, // LATIN SMALL LETTER N WITH CARON + {0x0149, "\x6E"}, // LATIN SMALL LETTER N PRECEDED BY APOSTROPHE + {0x014C, "\x4F"}, // LATIN CAPITAL LETTER O WITH MACRON + {0x014D, "\x6F"}, // LATIN SMALL LETTER O WITH MACRON + {0x014E, "\x4F"}, // LATIN CAPITAL LETTER O WITH BREVE + {0x014F, "\x6F"}, // LATIN SMALL LETTER O WITH BREVE + {0x0150, "\x4F"}, // LATIN CAPITAL LETTER O WITH DOUBLE ACUTE + {0x0151, "\x6F"}, // LATIN SMALL LETTER O WITH DOUBLE ACUTE + {0x0154, "\x52"}, // LATIN CAPITAL LETTER R WITH ACUTE + {0x0155, "\x72"}, // LATIN SMALL LETTER R WITH ACUTE + {0x0156, "\x52"}, // LATIN CAPITAL LETTER R WITH CEDILLA + {0x0157, "\x72"}, // LATIN SMALL LETTER R WITH CEDILLA + {0x0158, "\x52"}, // LATIN CAPITAL LETTER R WITH CARON + {0x0159, "\x72"}, // LATIN SMALL LETTER R WITH CARON + {0x015A, "\x53"}, // LATIN CAPITAL LETTER S WITH ACUTE + {0x015B, "\x73"}, // LATIN SMALL LETTER S WITH ACUTE + {0x015C, "\x53"}, // LATIN CAPITAL LETTER S WITH CIRCUMFLEX + {0x015D, "\x73"}, // LATIN SMALL LETTER S WITH CIRCUMFLEX + {0x015E, "\x53"}, // LATIN CAPITAL LETTER S WITH CEDILLA + {0x015F, "\x73"}, // LATIN SMALL LETTER S WITH CEDILLA + {0x0160, "\x53"}, // LATIN CAPITAL LETTER S WITH CARON + {0x0161, "\x73"}, // LATIN SMALL LETTER S WITH CARON + {0x0162, "\x54"}, // LATIN CAPITAL LETTER T WITH CEDILLA + {0x0163, "\x74"}, // LATIN SMALL LETTER T WITH CEDILLA + {0x0164, "\x54"}, // LATIN CAPITAL LETTER T WITH CARON + {0x0165, "\x74"}, // LATIN SMALL LETTER T WITH CARON + {0x0168, "\x55"}, // LATIN CAPITAL LETTER U WITH TILDE + {0x0169, "\x75"}, // LATIN SMALL LETTER U WITH TILDE + {0x016A, "\x55"}, // LATIN CAPITAL LETTER U WITH MACRON + {0x016B, "\x75"}, // LATIN SMALL LETTER U WITH MACRON + {0x016C, "\x55"}, // LATIN CAPITAL LETTER U WITH BREVE + {0x016D, "\x75"}, // LATIN SMALL LETTER U WITH BREVE + {0x016E, "\x55"}, // LATIN CAPITAL LETTER U WITH RING ABOVE + {0x016F, "\x75"}, // LATIN SMALL LETTER U WITH RING ABOVE + {0x0170, "\x55"}, // LATIN CAPITAL LETTER U WITH DOUBLE ACUTE + {0x0171, "\x75"}, // LATIN SMALL LETTER U WITH DOUBLE ACUTE + {0x0172, "\x55"}, // LATIN CAPITAL LETTER U WITH OGONEK + {0x0173, "\x75"}, // LATIN SMALL LETTER U WITH OGONEK + {0x0174, "\x57"}, // LATIN CAPITAL LETTER W WITH CIRCUMFLEX + {0x0175, "\x77"}, // LATIN SMALL LETTER W WITH CIRCUMFLEX + {0x0176, "\x59"}, // LATIN CAPITAL LETTER Y WITH CIRCUMFLEX + {0x0177, "\x79"}, // LATIN SMALL LETTER Y WITH CIRCUMFLEX + {0x0178, "\x59"}, // LATIN CAPITAL LETTER Y WITH DIAERESIS + {0x0179, "\x5A"}, // LATIN CAPITAL LETTER Z WITH ACUTE + {0x017A, "\x7A"}, // LATIN SMALL LETTER Z WITH ACUTE + {0x017B, "\x5A"}, // LATIN CAPITAL LETTER Z WITH DOT ABOVE + {0x017C, "\x7A"}, // LATIN SMALL LETTER Z WITH DOT ABOVE + {0x017D, "\x5A"}, // LATIN CAPITAL LETTER Z WITH CARON + {0x017E, "\x7A"}, // LATIN SMALL LETTER Z WITH CARON + {0x017F, "\x73"}, // LATIN SMALL LETTER LONG S + {0x01A0, "\x4F"}, // LATIN CAPITAL LETTER O WITH HORN + {0x01A1, "\x6F"}, // LATIN SMALL LETTER O WITH HORN + {0x01AF, "\x55"}, // LATIN CAPITAL LETTER U WITH HORN + {0x01B0, "\x75"}, // LATIN SMALL LETTER U WITH HORN + {0x01C4, "\x44\x5A"}, // LATIN CAPITAL LETTER DZ WITH CARON + {0x01C5, "\x44\x7A"}, // LATIN CAPITAL LETTER D WITH SMALL LETTER Z WITH CARON + {0x01C6, "\x64\x7A"}, // LATIN SMALL LETTER DZ WITH CARON + {0x01C7, "\x4C\x4A"}, // LATIN CAPITAL LETTER LJ + {0x01C8, "\x4C\x6A"}, // LATIN CAPITAL LETTER L WITH SMALL LETTER J + {0x01C9, "\x6C\x6A"}, // LATIN SMALL LETTER LJ + {0x01CA, "\x4E\x4A"}, // LATIN CAPITAL LETTER NJ + {0x01CB, "\x4E\x6A"}, // LATIN CAPITAL LETTER N WITH SMALL LETTER J + {0x01CC, "\x6E\x6A"}, // LATIN SMALL LETTER NJ + {0x01CD, "\x41"}, // LATIN CAPITAL LETTER A WITH CARON + {0x01CE, "\x61"}, // LATIN SMALL LETTER A WITH CARON + {0x01CF, "\x49"}, // LATIN CAPITAL LETTER I WITH CARON + {0x01D0, "\x69"}, // LATIN SMALL LETTER I WITH CARON + {0x01D1, "\x4F"}, // LATIN CAPITAL LETTER O WITH CARON + {0x01D2, "\x6F"}, // LATIN SMALL LETTER O WITH CARON + {0x01D3, "\x55"}, // LATIN CAPITAL LETTER U WITH CARON + {0x01D4, "\x75"}, // LATIN SMALL LETTER U WITH CARON + {0x01D5, "\x55"}, // LATIN CAPITAL LETTER U WITH DIAERESIS AND MACRON + {0x01D6, "\x75"}, // LATIN SMALL LETTER U WITH DIAERESIS AND MACRON + {0x01D7, "\x55"}, // LATIN CAPITAL LETTER U WITH DIAERESIS AND ACUTE + {0x01D8, "\x75"}, // LATIN SMALL LETTER U WITH DIAERESIS AND ACUTE + {0x01D9, "\x55"}, // LATIN CAPITAL LETTER U WITH DIAERESIS AND CARON + {0x01DA, "\x75"}, // LATIN SMALL LETTER U WITH DIAERESIS AND CARON + {0x01DB, "\x55"}, // LATIN CAPITAL LETTER U WITH DIAERESIS AND GRAVE + {0x01DC, "\x75"}, // LATIN SMALL LETTER U WITH DIAERESIS AND GRAVE + {0x01DE, "\x41"}, // LATIN CAPITAL LETTER A WITH DIAERESIS AND MACRON + {0x01DF, "\x61"}, // LATIN SMALL LETTER A WITH DIAERESIS AND MACRON + {0x01E0, "\x41"}, // LATIN CAPITAL LETTER A WITH DOT ABOVE AND MACRON + {0x01E1, "\x61"}, // LATIN SMALL LETTER A WITH DOT ABOVE AND MACRON + {0x01E6, "\x47"}, // LATIN CAPITAL LETTER G WITH CARON + {0x01E7, "\x67"}, // LATIN SMALL LETTER G WITH CARON + {0x01E8, "\x4B"}, // LATIN CAPITAL LETTER K WITH CARON + {0x01E9, "\x6B"}, // LATIN SMALL LETTER K WITH CARON + {0x01EA, "\x4F"}, // LATIN CAPITAL LETTER O WITH OGONEK + {0x01EB, "\x6F"}, // LATIN SMALL LETTER O WITH OGONEK + {0x01EC, "\x4F"}, // LATIN CAPITAL LETTER O WITH OGONEK AND MACRON + {0x01ED, "\x6F"}, // LATIN SMALL LETTER O WITH OGONEK AND MACRON + {0x01F0, "\x6A"}, // LATIN SMALL LETTER J WITH CARON + {0x01F1, "\x44\x5A"}, // LATIN CAPITAL LETTER DZ + {0x01F2, "\x44\x7A"}, // LATIN CAPITAL LETTER D WITH SMALL LETTER Z + {0x01F3, "\x64\x7A"}, // LATIN SMALL LETTER DZ + {0x01F4, "\x47"}, // LATIN CAPITAL LETTER G WITH ACUTE + {0x01F5, "\x67"}, // LATIN SMALL LETTER G WITH ACUTE + {0x01F8, "\x4E"}, // LATIN CAPITAL LETTER N WITH GRAVE + {0x01F9, "\x6E"}, // LATIN SMALL LETTER N WITH GRAVE + {0x01FA, "\x41"}, // LATIN CAPITAL LETTER A WITH RING ABOVE AND ACUTE + {0x01FB, "\x61"}, // LATIN SMALL LETTER A WITH RING ABOVE AND ACUTE + {0x0200, "\x41"}, // LATIN CAPITAL LETTER A WITH DOUBLE GRAVE + {0x0201, "\x61"}, // LATIN SMALL LETTER A WITH DOUBLE GRAVE + {0x0202, "\x41"}, // LATIN CAPITAL LETTER A WITH INVERTED BREVE + {0x0203, "\x61"}, // LATIN SMALL LETTER A WITH INVERTED BREVE + {0x0204, "\x45"}, // LATIN CAPITAL LETTER E WITH DOUBLE GRAVE + {0x0205, "\x65"}, // LATIN SMALL LETTER E WITH DOUBLE GRAVE + {0x0206, "\x45"}, // LATIN CAPITAL LETTER E WITH INVERTED BREVE + {0x0207, "\x65"}, // LATIN SMALL LETTER E WITH INVERTED BREVE + {0x0208, "\x49"}, // LATIN CAPITAL LETTER I WITH DOUBLE GRAVE + {0x0209, "\x69"}, // LATIN SMALL LETTER I WITH DOUBLE GRAVE + {0x020A, "\x49"}, // LATIN CAPITAL LETTER I WITH INVERTED BREVE + {0x020B, "\x69"}, // LATIN SMALL LETTER I WITH INVERTED BREVE + {0x020C, "\x4F"}, // LATIN CAPITAL LETTER O WITH DOUBLE GRAVE + {0x020D, "\x6F"}, // LATIN SMALL LETTER O WITH DOUBLE GRAVE + {0x020E, "\x4F"}, // LATIN CAPITAL LETTER O WITH INVERTED BREVE + {0x020F, "\x6F"}, // LATIN SMALL LETTER O WITH INVERTED BREVE + {0x0210, "\x52"}, // LATIN CAPITAL LETTER R WITH DOUBLE GRAVE + {0x0211, "\x72"}, // LATIN SMALL LETTER R WITH DOUBLE GRAVE + {0x0212, "\x52"}, // LATIN CAPITAL LETTER R WITH INVERTED BREVE + {0x0213, "\x72"}, // LATIN SMALL LETTER R WITH INVERTED BREVE + {0x0214, "\x55"}, // LATIN CAPITAL LETTER U WITH DOUBLE GRAVE + {0x0215, "\x75"}, // LATIN SMALL LETTER U WITH DOUBLE GRAVE + {0x0216, "\x55"}, // LATIN CAPITAL LETTER U WITH INVERTED BREVE + {0x0217, "\x75"}, // LATIN SMALL LETTER U WITH INVERTED BREVE + {0x0218, "\x53"}, // LATIN CAPITAL LETTER S WITH COMMA BELOW + {0x0219, "\x73"}, // LATIN SMALL LETTER S WITH COMMA BELOW + {0x021A, "\x54"}, // LATIN CAPITAL LETTER T WITH COMMA BELOW + {0x021B, "\x74"}, // LATIN SMALL LETTER T WITH COMMA BELOW + {0x021E, "\x48"}, // LATIN CAPITAL LETTER H WITH CARON + {0x021F, "\x68"}, // LATIN SMALL LETTER H WITH CARON + {0x0226, "\x41"}, // LATIN CAPITAL LETTER A WITH DOT ABOVE + {0x0227, "\x61"}, // LATIN SMALL LETTER A WITH DOT ABOVE + {0x0228, "\x45"}, // LATIN CAPITAL LETTER E WITH CEDILLA + {0x0229, "\x65"}, // LATIN SMALL LETTER E WITH CEDILLA + {0x022A, "\x4F"}, // LATIN CAPITAL LETTER O WITH DIAERESIS AND MACRON + {0x022B, "\x6F"}, // LATIN SMALL LETTER O WITH DIAERESIS AND MACRON + {0x022C, "\x4F"}, // LATIN CAPITAL LETTER O WITH TILDE AND MACRON + {0x022D, "\x6F"}, // LATIN SMALL LETTER O WITH TILDE AND MACRON + {0x022E, "\x4F"}, // LATIN CAPITAL LETTER O WITH DOT ABOVE + {0x022F, "\x6F"}, // LATIN SMALL LETTER O WITH DOT ABOVE + {0x0230, "\x4F"}, // LATIN CAPITAL LETTER O WITH DOT ABOVE AND MACRON + {0x0231, "\x6F"}, // LATIN SMALL LETTER O WITH DOT ABOVE AND MACRON + {0x0232, "\x59"}, // LATIN CAPITAL LETTER Y WITH MACRON + {0x0233, "\x79"}, // LATIN SMALL LETTER Y WITH MACRON + {0x0404, "\xD0\xAD"}, // CYRILLIC CAPITAL LETTER UKRAINIAN IE + {0x0405, "\xD0\xA1"}, // CYRILLIC CAPITAL LETTER DZE + {0x0406, "\xD0\x98"}, // CYRILLIC CAPITAL LETTER BYELORUSSIAN-UKRAINIAN I + {0x0407, "\xD0\x98"}, // CYRILLIC CAPITAL LETTER YI + {0x0408, "\x4A"}, // CYRILLIC CAPITAL LETTER JE + {0x040E, "\xD0\xA3"}, // CYRILLIC CAPITAL LETTER SHORT U + {0x0454, "\xD1\x8D"}, // CYRILLIC SMALL LETTER UKRAINIAN IE + {0x0455, "\xD1\x81"}, // CYRILLIC SMALL LETTER DZE + {0x0456, "\xD0\xB8"}, // CYRILLIC SMALL LETTER BYELORUSSIAN-UKRAINIAN I + {0x0457, "\xD0\xB8"}, // CYRILLIC SMALL LETTER YI + {0x0458, "\x6A"}, // CYRILLIC SMALL LETTER JE + {0x045E, "\xD1\x83"}, // CYRILLIC SMALL LETTER SHORT U + {0x0490, "\xD0\x93"}, // CYRILLIC CAPITAL LETTER GHE WITH UPTURN + {0x0491, "\xD0\xB3"}, // CYRILLIC SMALL LETTER GHE WITH UPTURN + {0x2007, "\x20"}, // FIGURE SPACE + {0x2009, "\x20"}, // THIN SPACE + {0x200B, ""}, // ZERO WIDTH SPACE + {0x2010, "\x2D"}, // HYPHEN + {0x2011, "\x2D"}, // NON-BREAKING HYPHEN + {0x2012, "\x2D"}, // FIGURE DASH + {0x2013, "\x2D"}, // EN DASH + {0x2014, "\x2D"}, // EM DASH + {0x2015, "\x2D"}, // HORIZONTAL BAR + {0x2018, "\x27"}, // LEFT SINGLE QUOTATION MARK + {0x2019, "\x27"}, // RIGHT SINGLE QUOTATION MARK + {0x201A, "\x2C"}, // SINGLE LOW-9 QUOTATION MARK + {0x201B, "\x27"}, // SINGLE HIGH-REVERSED-9 QUOTATION MARK + {0x201C, "\x22"}, // LEFT DOUBLE QUOTATION MARK + {0x201D, "\x22"}, // RIGHT DOUBLE QUOTATION MARK + {0x201E, "\x22"}, // DOUBLE LOW-9 QUOTATION MARK + {0x201F, "\x22"}, // DOUBLE HIGH-REVERSED-9 QUOTATION MARK + {0x2022, "\x2A"}, // BULLET + {0x2023, "\x3E"}, // TRIANGULAR BULLET + {0x2026, "\x2E\x2E\x2E"}, // HORIZONTAL ELLIPSIS + {0x2027, "\x2A"}, // HYPHENATION POINT + {0x2028, "\x20"}, // LINE SEPARATOR + {0x2029, "\x20"}, // PARAGRAPH SEPARATOR + {0x202F, "\x20"}, // NARROW NO-BREAK SPACE + {0x2032, "\x27"}, // PRIME + {0x2033, "\x22"}, // DOUBLE PRIME + {0x2039, "\x3C"}, // SINGLE LEFT-POINTING ANGLE QUOTATION MARK + {0x203A, "\x3E"}, // SINGLE RIGHT-POINTING ANGLE QUOTATION MARK + {0x2043, "\x2D"}, // HYPHEN BULLET + {0x20AC, "\x45\x55\x52"}, // EURO SIGN + {0x2122, "\x74\x6D"}, // TRADE MARK SIGN + {0x2190, "\x3C\x2D"}, // LEFTWARDS ARROW + {0x2191, "\x5E"}, // UPWARDS ARROW + {0x2192, "\x2D\x3E"}, // RIGHTWARDS ARROW + {0x2193, "\x76"}, // DOWNWARDS ARROW + {0x21D0, "\x3C\x3D"}, // LEFTWARDS DOUBLE ARROW + {0x21D2, "\x3D\x3E"}, // RIGHTWARDS DOUBLE ARROW + {0x2212, "\x2D"}, // MINUS SIGN + {0x221E, "\x69\x6E\x66"}, // INFINITY + {0x2260, "\x21\x3D"}, // NOT EQUAL TO + {0x2501, "\xE2\x94\x80"}, // BOX DRAWINGS HEAVY HORIZONTAL + {0x2503, "\xE2\x94\x82"}, // BOX DRAWINGS HEAVY VERTICAL + {0x2504, "\xE2\x94\x80"}, // BOX DRAWINGS LIGHT TRIPLE DASH HORIZONTAL + {0x2505, "\xE2\x94\x80"}, // BOX DRAWINGS HEAVY TRIPLE DASH HORIZONTAL + {0x2506, "\xE2\x94\x82"}, // BOX DRAWINGS LIGHT TRIPLE DASH VERTICAL + {0x2507, "\xE2\x94\x82"}, // BOX DRAWINGS HEAVY TRIPLE DASH VERTICAL + {0x2508, "\xE2\x94\x80"}, // BOX DRAWINGS LIGHT QUADRUPLE DASH HORIZONTAL + {0x2509, "\xE2\x94\x80"}, // BOX DRAWINGS HEAVY QUADRUPLE DASH HORIZONTAL + {0x250A, "\xE2\x94\x82"}, // BOX DRAWINGS LIGHT QUADRUPLE DASH VERTICAL + {0x250B, "\xE2\x94\x82"}, // BOX DRAWINGS HEAVY QUADRUPLE DASH VERTICAL + {0x250D, "\x2B"}, // BOX DRAWINGS DOWN LIGHT AND RIGHT HEAVY + {0x250E, "\x2B"}, // BOX DRAWINGS DOWN HEAVY AND RIGHT LIGHT + {0x250F, "\xE2\x94\x8C"}, // BOX DRAWINGS HEAVY DOWN AND RIGHT + {0x2511, "\x2B"}, // BOX DRAWINGS DOWN LIGHT AND LEFT HEAVY + {0x2512, "\x2B"}, // BOX DRAWINGS DOWN HEAVY AND LEFT LIGHT + {0x2513, "\xE2\x94\x90"}, // BOX DRAWINGS HEAVY DOWN AND LEFT + {0x2515, "\x2B"}, // BOX DRAWINGS UP LIGHT AND RIGHT HEAVY + {0x2516, "\x2B"}, // BOX DRAWINGS UP HEAVY AND RIGHT LIGHT + {0x2517, "\xE2\x94\x94"}, // BOX DRAWINGS HEAVY UP AND RIGHT + {0x2519, "\x2B"}, // BOX DRAWINGS UP LIGHT AND LEFT HEAVY + {0x251A, "\x2B"}, // BOX DRAWINGS UP HEAVY AND LEFT LIGHT + {0x251B, "\xE2\x94\x98"}, // BOX DRAWINGS HEAVY UP AND LEFT + {0x251D, "\x7C"}, // BOX DRAWINGS VERTICAL LIGHT AND RIGHT HEAVY + {0x251E, "\x2B"}, // BOX DRAWINGS UP HEAVY AND RIGHT DOWN LIGHT + {0x251F, "\x2B"}, // BOX DRAWINGS DOWN HEAVY AND RIGHT UP LIGHT + {0x2520, "\x7C"}, // BOX DRAWINGS VERTICAL HEAVY AND RIGHT LIGHT + {0x2521, "\x2B"}, // BOX DRAWINGS DOWN LIGHT AND RIGHT UP HEAVY + {0x2522, "\x2B"}, // BOX DRAWINGS UP LIGHT AND RIGHT DOWN HEAVY + {0x2523, "\xE2\x94\x9C"}, // BOX DRAWINGS HEAVY VERTICAL AND RIGHT + {0x2525, "\x7C"}, // BOX DRAWINGS VERTICAL LIGHT AND LEFT HEAVY + {0x2526, "\x2B"}, // BOX DRAWINGS UP HEAVY AND LEFT DOWN LIGHT + {0x2527, "\x2B"}, // BOX DRAWINGS DOWN HEAVY AND LEFT UP LIGHT + {0x2528, "\x7C"}, // BOX DRAWINGS VERTICAL HEAVY AND LEFT LIGHT + {0x2529, "\x2B"}, // BOX DRAWINGS DOWN LIGHT AND LEFT UP HEAVY + {0x252A, "\x2B"}, // BOX DRAWINGS UP LIGHT AND LEFT DOWN HEAVY + {0x252B, "\xE2\x94\xA4"}, // BOX DRAWINGS HEAVY VERTICAL AND LEFT + {0x252D, "\x2B"}, // BOX DRAWINGS LEFT HEAVY AND RIGHT DOWN LIGHT + {0x252E, "\x2B"}, // BOX DRAWINGS RIGHT HEAVY AND LEFT DOWN LIGHT + {0x252F, "\x2D"}, // BOX DRAWINGS DOWN LIGHT AND HORIZONTAL HEAVY + {0x2530, "\x2D"}, // BOX DRAWINGS DOWN HEAVY AND HORIZONTAL LIGHT + {0x2531, "\x2B"}, // BOX DRAWINGS RIGHT LIGHT AND LEFT DOWN HEAVY + {0x2532, "\x2B"}, // BOX DRAWINGS LEFT LIGHT AND RIGHT DOWN HEAVY + {0x2533, "\xE2\x94\xAC"}, // BOX DRAWINGS HEAVY DOWN AND HORIZONTAL + {0x2535, "\x2B"}, // BOX DRAWINGS LEFT HEAVY AND RIGHT UP LIGHT + {0x2536, "\x2B"}, // BOX DRAWINGS RIGHT HEAVY AND LEFT UP LIGHT + {0x2537, "\x2D"}, // BOX DRAWINGS UP LIGHT AND HORIZONTAL HEAVY + {0x2538, "\x2D"}, // BOX DRAWINGS UP HEAVY AND HORIZONTAL LIGHT + {0x2539, "\x2B"}, // BOX DRAWINGS RIGHT LIGHT AND LEFT UP HEAVY + {0x253A, "\x2B"}, // BOX DRAWINGS LEFT LIGHT AND RIGHT UP HEAVY + {0x253B, "\xE2\x94\xB4"}, // BOX DRAWINGS HEAVY UP AND HORIZONTAL + {0x253D, "\x7C"}, // BOX DRAWINGS LEFT HEAVY AND RIGHT VERTICAL LIGHT + {0x253E, "\x7C"}, // BOX DRAWINGS RIGHT HEAVY AND LEFT VERTICAL LIGHT + {0x253F, "\x7C"}, // BOX DRAWINGS VERTICAL LIGHT AND HORIZONTAL HEAVY + {0x2540, "\x2D"}, // BOX DRAWINGS UP HEAVY AND DOWN HORIZONTAL LIGHT + {0x2541, "\x2D"}, // BOX DRAWINGS DOWN HEAVY AND UP HORIZONTAL LIGHT + {0x2542, "\x7C"}, // BOX DRAWINGS VERTICAL HEAVY AND HORIZONTAL LIGHT + {0x2543, "\x2B"}, // BOX DRAWINGS LEFT UP HEAVY AND RIGHT DOWN LIGHT + {0x2544, "\x2B"}, // BOX DRAWINGS RIGHT UP HEAVY AND LEFT DOWN LIGHT + {0x2545, "\x2B"}, // BOX DRAWINGS LEFT DOWN HEAVY AND RIGHT UP LIGHT + {0x2546, "\x2B"}, // BOX DRAWINGS RIGHT DOWN HEAVY AND LEFT UP LIGHT + {0x2547, "\x2D"}, // BOX DRAWINGS DOWN LIGHT AND UP HORIZONTAL HEAVY + {0x2548, "\x2D"}, // BOX DRAWINGS UP LIGHT AND DOWN HORIZONTAL HEAVY + {0x2549, "\x7C"}, // BOX DRAWINGS RIGHT LIGHT AND LEFT VERTICAL HEAVY + {0x254A, "\x7C"}, // BOX DRAWINGS LEFT LIGHT AND RIGHT VERTICAL HEAVY + {0x254B, "\xE2\x94\xBC"}, // BOX DRAWINGS HEAVY VERTICAL AND HORIZONTAL + {0x254C, "\xE2\x94\x80"}, // BOX DRAWINGS LIGHT DOUBLE DASH HORIZONTAL + {0x254D, "\xE2\x94\x80"}, // BOX DRAWINGS HEAVY DOUBLE DASH HORIZONTAL + {0x254E, "\xE2\x94\x82"}, // BOX DRAWINGS LIGHT DOUBLE DASH VERTICAL + {0x254F, "\xE2\x94\x82"}, // BOX DRAWINGS HEAVY DOUBLE DASH VERTICAL + {0x256D, "\xE2\x94\x8C"}, // BOX DRAWINGS LIGHT ARC DOWN AND RIGHT + {0x256E, "\xE2\x94\x90"}, // BOX DRAWINGS LIGHT ARC DOWN AND LEFT + {0x256F, "\xE2\x94\x98"}, // BOX DRAWINGS LIGHT ARC UP AND LEFT + {0x2570, "\xE2\x94\x94"}, // BOX DRAWINGS LIGHT ARC UP AND RIGHT + {0x2571, "\x2B"}, // BOX DRAWINGS LIGHT DIAGONAL UPPER RIGHT TO LOWER LEFT + {0x2572, "\x2B"}, // BOX DRAWINGS LIGHT DIAGONAL UPPER LEFT TO LOWER RIGHT + {0x2573, "\x2B"}, // BOX DRAWINGS LIGHT DIAGONAL CROSS + {0x2574, "\x2B"}, // BOX DRAWINGS LIGHT LEFT + {0x2575, "\x2B"}, // BOX DRAWINGS LIGHT UP + {0x2576, "\x2B"}, // BOX DRAWINGS LIGHT RIGHT + {0x2577, "\x2B"}, // BOX DRAWINGS LIGHT DOWN + {0x2578, "\x2B"}, // BOX DRAWINGS HEAVY LEFT + {0x2579, "\x2B"}, // BOX DRAWINGS HEAVY UP + {0x257A, "\x2B"}, // BOX DRAWINGS HEAVY RIGHT + {0x257B, "\x2B"}, // BOX DRAWINGS HEAVY DOWN + {0x257C, "\x2B"}, // BOX DRAWINGS LIGHT LEFT AND HEAVY RIGHT + {0x257D, "\x2B"}, // BOX DRAWINGS LIGHT UP AND HEAVY DOWN + {0x257E, "\x2B"}, // BOX DRAWINGS HEAVY LEFT AND LIGHT RIGHT + {0x257F, "\x2B"}, // BOX DRAWINGS HEAVY UP AND LIGHT DOWN + {0x2581, "\xE2\x96\x84"}, // LOWER ONE EIGHTH BLOCK + {0x2582, "\xE2\x96\x84"}, // LOWER ONE QUARTER BLOCK + {0x2583, "\xE2\x96\x84"}, // LOWER THREE EIGHTHS BLOCK + {0x2585, "\xE2\x96\x84"}, // LOWER FIVE EIGHTHS BLOCK + {0x2586, "\xE2\x96\x84"}, // LOWER THREE QUARTERS BLOCK + {0x2587, "\xE2\x96\x84"}, // LOWER SEVEN EIGHTHS BLOCK + {0x2589, "\xE2\x96\x88"}, // LEFT SEVEN EIGHTHS BLOCK + {0x258A, "\xE2\x96\x88"}, // LEFT THREE QUARTERS BLOCK + {0x258B, "\xE2\x96\x88"}, // LEFT FIVE EIGHTHS BLOCK + {0x258D, "\xE2\x96\x8C"}, // LEFT THREE EIGHTHS BLOCK + {0x258E, "\xE2\x96\x8C"}, // LEFT ONE QUARTER BLOCK + {0x258F, "\xE2\x96\x8C"}, // LEFT ONE EIGHTH BLOCK + {0x2594, "\xE2\x96\x80"}, // UPPER ONE EIGHTH BLOCK + {0x2595, "\xE2\x96\x90"}, // RIGHT ONE EIGHTH BLOCK + {0x2596, "\xE2\x96\x84"}, // QUADRANT LOWER LEFT + {0x2597, "\xE2\x96\x84"}, // QUADRANT LOWER RIGHT + {0x2598, "\xE2\x96\x80"}, // QUADRANT UPPER LEFT + {0x2599, "\xE2\x96\x88"}, // QUADRANT UPPER LEFT AND LOWER LEFT AND LOWER RIGHT + {0x259A, "\xE2\x96\x92"}, // QUADRANT UPPER LEFT AND LOWER RIGHT + {0x259B, "\xE2\x96\x88"}, // QUADRANT UPPER LEFT AND UPPER RIGHT AND LOWER LEFT + {0x259C, "\xE2\x96\x88"}, // QUADRANT UPPER LEFT AND UPPER RIGHT AND LOWER RIGHT + {0x259D, "\xE2\x96\x80"}, // QUADRANT UPPER RIGHT + {0x259E, "\xE2\x96\x92"}, // QUADRANT UPPER RIGHT AND LOWER LEFT + {0x259F, "\xE2\x96\x88"}, // QUADRANT UPPER RIGHT AND LOWER LEFT AND LOWER RIGHT + {0x25A1, "\x23"}, // WHITE SQUARE + {0x25AA, "\x2A"}, // BLACK SMALL SQUARE + {0x25AB, "\x2A"}, // WHITE SMALL SQUARE + {0x25B2, "\x5E"}, // BLACK UP-POINTING TRIANGLE + {0x25B6, "\x3E"}, // BLACK RIGHT-POINTING TRIANGLE + {0x25BC, "\x76"}, // BLACK DOWN-POINTING TRIANGLE + {0x25C0, "\x3C"}, // BLACK LEFT-POINTING TRIANGLE + {0x25CB, "\x6F"}, // WHITE CIRCLE + {0x25CF, "\x2A"}, // BLACK CIRCLE + {0x2605, "\x2A"}, // BLACK STAR + {0x2606, "\x2A"}, // WHITE STAR + {0x2620, "\x21"}, // SKULL AND CROSSBONES + {0x2660, "\x53"}, // BLACK SPADE SUIT + {0x2663, "\x43"}, // BLACK CLUB SUIT + {0x2665, "\x48"}, // BLACK HEART SUIT + {0x2666, "\x44"}, // BLACK DIAMOND SUIT + {0x2694, "\x78"}, // CROSSED SWORDS + {0x2713, "\x2B"}, // CHECK MARK + {0x2714, "\x2B"}, // HEAVY CHECK MARK + {0x2717, "\x78"}, // BALLOT X + {0x2718, "\x78"}, // HEAVY BALLOT X + {0x2726, "\x2A"}, // BLACK FOUR POINTED STAR + {0x2727, "\x2A"}, // WHITE FOUR POINTED STAR + {0x272A, "\x2A"}, // CIRCLED WHITE STAR + {0x2734, "\x2A"}, // EIGHT POINTED BLACK STAR + {0x2736, "\x2A"}, // SIX POINTED BLACK STAR +}; + +} // namespace + +const char *TranslitToKoi8(char32_t code_point) { + const auto *end = kTable + std::size(kTable); + const auto *it = std::lower_bound(kTable, end, code_point, + [](const TranslitEntry &entry, char32_t value) { return entry.code_point < value; }); + return (it != end && it->code_point == code_point) ? it->replacement : nullptr; +} + +std::string Utf8ToKoi8(const std::string &text) { + if (text.empty()) { + return text; + } + std::string reduced; + reduced.reserve(text.size()); + for (std::size_t pos = 0; pos < text.size();) { + char32_t cp = 0; + const std::size_t len = utf8::decode(text, pos, cp); + if (len == 0) { + break; + } + const char *const replacement = TranslitToKoi8(cp); + if (replacement != nullptr) { + reduced.append(replacement); + } else { + reduced.append(text, pos, len); + } + pos += len; + } + + // KOI8-R is never longer than the UTF-8 it came from, so the input size is a safe bound. + std::vector out(reduced.size() + 1, '\0'); + utf8_to_koi(const_cast(reduced.c_str()), out.data()); + return std::string(out.data()); +} + +} // namespace codepages + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/utils/translit_koi8.h b/src/utils/translit_koi8.h new file mode 100644 index 0000000000..34cc77dd30 --- /dev/null +++ b/src/utils/translit_koi8.h @@ -0,0 +1,28 @@ +/** +\file translit_koi8.h - a part of the Bylins engine. +\brief Closest KOI8-R equivalent for characters KOI8-R does not have (issue #3681). +*/ + +#ifndef BYLINS_SRC_UTILS_TRANSLIT_KOI8_H_ +#define BYLINS_SRC_UTILS_TRANSLIT_KOI8_H_ + +#include + +namespace codepages { + +// UTF-8 replacement for a code point KOI8-R cannot represent, or nullptr when there is no +// sensible one (the caller then falls back to its placeholder). Replacements may be longer +// than one character: "..." for an ellipsis, "(tm)" for a trademark sign. +const char *TranslitToKoi8(char32_t code_point); + +// UTF-8 text -> KOI8-R, running the table above as a pre-pass. Doing the substitution before the +// conversion is what allows a replacement to be longer than one character: the converter itself +// writes exactly one output byte per code point. No replacement ever grows the text (pinned by a +// test), so the result is never longer than the input. +std::string Utf8ToKoi8(const std::string &text); + +} // namespace codepages + +#endif // BYLINS_SRC_UTILS_TRANSLIT_KOI8_H_ + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/utils/utf8.cpp b/src/utils/utf8.cpp new file mode 100644 index 0000000000..8941786da3 --- /dev/null +++ b/src/utils/utf8.cpp @@ -0,0 +1,286 @@ +/** +\file utf8.cpp - a part of the Bylins engine. +\brief Implementation of the character-semantic UTF-8 helpers declared in utf8.h (issue #3681). +*/ + +#include "utf8.h" + +namespace utf8 { + +namespace { + +// Result of decoding one position: the code point, the byte length consumed, and whether the +// sequence was well-formed. On a malformed byte, `valid` is false, `len` is 1 and `cp` is the +// raw byte -- so scanners always advance and lenient callers can pass the byte through untouched. +struct Decoded { + char32_t cp; + std::size_t len; + bool valid; +}; + +// Decode per the Unicode 3-7 grammar: the first continuation byte has a lead-specific range +// (which is what rejects overlong forms and surrogates), the rest are plain 0x80..0xBF. +Decoded decode_core(std::string_view s, std::size_t pos) { + const std::size_t n = s.size(); + const unsigned char c0 = static_cast(s[pos]); + if (c0 < 0x80) { + return {c0, 1, true}; + } + + int len = 0; + char32_t cp = 0; + unsigned char b1_lo = 0x80; + unsigned char b1_hi = 0xBF; + if (c0 >= 0xC2 && c0 <= 0xDF) { + len = 2; + cp = c0 & 0x1F; + } else if (c0 == 0xE0) { + len = 3; + cp = c0 & 0x0F; + b1_lo = 0xA0; + } else if (c0 >= 0xE1 && c0 <= 0xEC) { + len = 3; + cp = c0 & 0x0F; + } else if (c0 == 0xED) { + len = 3; + cp = c0 & 0x0F; + b1_hi = 0x9F; + } else if (c0 >= 0xEE && c0 <= 0xEF) { + len = 3; + cp = c0 & 0x0F; + } else if (c0 == 0xF0) { + len = 4; + cp = c0 & 0x07; + b1_lo = 0x90; + } else if (c0 >= 0xF1 && c0 <= 0xF3) { + len = 4; + cp = c0 & 0x07; + } else if (c0 == 0xF4) { + len = 4; + cp = c0 & 0x07; + b1_hi = 0x8F; + } else { + // 0xC0, 0xC1, 0xF5..0xFF or a stray continuation byte: cannot start a sequence. + return {c0, 1, false}; + } + + if (pos + static_cast(len) > n) { + return {c0, 1, false}; + } + + const unsigned char b1 = static_cast(s[pos + 1]); + if (b1 < b1_lo || b1 > b1_hi) { + return {c0, 1, false}; + } + cp = (cp << 6) | (b1 & 0x3F); + + for (int k = 2; k < len; ++k) { + const unsigned char b = static_cast(s[pos + static_cast(k)]); + if (b < 0x80 || b > 0xBF) { + return {c0, 1, false}; + } + cp = (cp << 6) | (b & 0x3F); + } + + return {cp, static_cast(len), true}; +} + +} // namespace + +int sequence_length(unsigned char c) { + if (c < 0x80) { + return 1; + } + if (c >= 0xC0 && c <= 0xDF) { + return 2; + } + if (c >= 0xE0 && c <= 0xEF) { + return 3; + } + if (c >= 0xF0 && c <= 0xF7) { + return 4; + } + // Continuation byte (0x80..0xBF) or an out-of-range lead (0xF8..0xFF): not a valid start. + return 1; +} + +std::size_t decode(std::string_view s, std::size_t pos, char32_t &cp) { + if (pos >= s.size()) { + cp = 0; + return 0; + } + const Decoded d = decode_core(s, pos); + cp = d.cp; + return d.len; +} + +std::size_t encode(char32_t cp, std::string &out) { + if (cp <= 0x7F) { + out.push_back(static_cast(cp)); + return 1; + } + if (cp <= 0x7FF) { + out.push_back(static_cast(0xC0 | (cp >> 6))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + return 2; + } + if (cp >= 0xD800 && cp <= 0xDFFF) { + return 0; // surrogate half: not a Unicode scalar value + } + if (cp <= 0xFFFF) { + out.push_back(static_cast(0xE0 | (cp >> 12))); + out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + return 3; + } + if (cp <= 0x10FFFF) { + out.push_back(static_cast(0xF0 | (cp >> 18))); + out.push_back(static_cast(0x80 | ((cp >> 12) & 0x3F))); + out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + return 4; + } + return 0; +} + +std::size_t encode(char32_t cp, char *out) { + if (cp <= 0x7F) { + out[0] = static_cast(cp); + return 1; + } + if (cp <= 0x7FF) { + out[0] = static_cast(0xC0 | (cp >> 6)); + out[1] = static_cast(0x80 | (cp & 0x3F)); + return 2; + } + if (cp >= 0xD800 && cp <= 0xDFFF) { + return 0; + } + if (cp <= 0xFFFF) { + out[0] = static_cast(0xE0 | (cp >> 12)); + out[1] = static_cast(0x80 | ((cp >> 6) & 0x3F)); + out[2] = static_cast(0x80 | (cp & 0x3F)); + return 3; + } + if (cp <= 0x10FFFF) { + out[0] = static_cast(0xF0 | (cp >> 18)); + out[1] = static_cast(0x80 | ((cp >> 12) & 0x3F)); + out[2] = static_cast(0x80 | ((cp >> 6) & 0x3F)); + out[3] = static_cast(0x80 | (cp & 0x3F)); + return 4; + } + return 0; +} + +bool is_valid(std::string_view s) { + std::size_t pos = 0; + const std::size_t n = s.size(); + while (pos < n) { + const Decoded d = decode_core(s, pos); + if (!d.valid) { + return false; + } + pos += d.len; + } + return true; +} + +std::size_t length(std::string_view s) { + std::size_t count = 0; + std::size_t pos = 0; + const std::size_t n = s.size(); + while (pos < n) { + pos += decode_core(s, pos).len; + ++count; + } + return count; +} + +std::size_t byte_offset(std::string_view s, std::size_t index) { + std::size_t pos = 0; + const std::size_t n = s.size(); + while (index > 0 && pos < n) { + pos += decode_core(s, pos).len; + --index; + } + return pos; +} + +std::string_view char_at(std::string_view s, std::size_t index) { + const std::size_t start = byte_offset(s, index); + if (start >= s.size()) { + return {}; + } + const std::size_t len = decode_core(s, start).len; + return s.substr(start, len); +} + +std::string substr(std::string_view s, std::size_t pos, std::size_t count) { + const std::size_t start = byte_offset(s, pos); + if (count == std::string_view::npos) { + return std::string(s.substr(start)); + } + const std::size_t stop = byte_offset(s, pos + count); + return std::string(s.substr(start, stop - start)); +} + +char32_t to_lower(char32_t cp) { + if (cp >= 'A' && cp <= 'Z') { + return cp + 0x20; + } + if (cp >= 0x0410 && cp <= 0x042F) { // U+0410..U+042F (upper) -> U+0430..U+044F (lower) + return cp + 0x20; + } + if (cp == 0x0401) { // U+0401 (Yo) -> U+0451 (yo) + return 0x0451; + } + return cp; +} + +char32_t to_upper(char32_t cp) { + if (cp >= 'a' && cp <= 'z') { + return cp - 0x20; + } + if (cp >= 0x0430 && cp <= 0x044F) { // U+0430..U+044F (lower) -> U+0410..U+042F (upper) + return cp - 0x20; + } + if (cp == 0x0451) { // U+0451 (yo) -> U+0401 (Yo) + return 0x0401; + } + return cp; +} + +namespace { + +// Shared body for the whole-string case folders: decode, fold each code point, re-encode. +// Malformed bytes (valid == false) are copied through verbatim so nothing is silently dropped. +std::string fold_string(std::string_view s, char32_t (*fold)(char32_t)) { + std::string out; + out.reserve(s.size()); + std::size_t pos = 0; + const std::size_t n = s.size(); + while (pos < n) { + const Decoded d = decode_core(s, pos); + if (d.valid) { + encode(fold(d.cp), out); + } else { + out.push_back(s[pos]); + } + pos += d.len; + } + return out; +} + +} // namespace + +std::string to_lower(std::string_view s) { + return fold_string(s, to_lower); +} + +std::string to_upper(std::string_view s) { + return fold_string(s, to_upper); +} + +} // namespace utf8 + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/utils/utf8.h b/src/utils/utf8.h new file mode 100644 index 0000000000..f65f32ec3d --- /dev/null +++ b/src/utils/utf8.h @@ -0,0 +1,72 @@ +/** +\file utf8.h - a part of the Bylins engine. +\brief Character-semantic helpers over UTF-8 strings (issue #3681, "Plan Napoleon"). + +This is the encoding-agnostic building block for the KOI8-R -> UTF-8 migration: code that +must reason about *characters* (code points) rather than bytes -- length, substring, indexed +access, case folding -- lives here. Everything is plain UTF-8 in / UTF-8 out with no external +dependency (no iconv/ICU). ASCII passes through untouched, so the helpers are also correct for +pure-ASCII input regardless of the ambient encoding. + +Case folding covers exactly what the legacy a_ucc/a_lcc tables covered: ASCII A-Z and the +Russian Cyrillic block (U+0410..U+044F plus Yo, U+0401/U+0451). Any other code point passes +through unchanged. +*/ + +#ifndef BYLINS_SRC_UTILS_UTF8_H_ +#define BYLINS_SRC_UTILS_UTF8_H_ + +#include +#include +#include + +namespace utf8 { + +// Number of bytes the UTF-8 sequence starting with lead byte `c` claims to span. +// Returns 1 for ASCII and for any byte that cannot start a sequence, so a caller that +// advances by the result always makes forward progress. +int sequence_length(unsigned char c); + +// Decode the code point starting at byte position `pos` in `s`. +// Writes the code point (or the raw byte, on a malformed sequence) to `cp` and returns the +// number of bytes consumed: >=1 while `pos` is in range, 0 once `pos >= s.size()`. +// Malformed sequences never stall: they yield the single offending byte and a length of 1. +std::size_t decode(std::string_view s, std::size_t pos, char32_t &cp); + +// Append `cp` to `out` as UTF-8. Returns the number of bytes written, or 0 for a value that is +// not a valid Unicode scalar (surrogate half or > U+10FFFF), in which case `out` is untouched. +std::size_t encode(char32_t cp, std::string &out); + +// Same, but writes into a caller-supplied buffer of at least 4 bytes and never allocates. +// Returns the number of bytes written, or 0 for a value that is not a Unicode scalar. +std::size_t encode(char32_t cp, char *out); + +// Strict, whole-string well-formedness check per the Unicode Table 3-7 byte-sequence grammar +// (rejects overlong forms, surrogates, code points above U+10FFFF and stray continuation bytes). +bool is_valid(std::string_view s); + +// Number of code points. Malformed bytes count as one code point each; never throws. +std::size_t length(std::string_view s); + +// Byte offset of the `index`-th code point, or `s.size()` when `index` is past the end. +std::size_t byte_offset(std::string_view s, std::size_t index); + +// The bytes of the `index`-th code point, as a view into `s`. Empty when `index` is out of range. +std::string_view char_at(std::string_view s, std::size_t index); + +// std::string::substr, but `pos`/`count` are counted in code points instead of bytes. +std::string substr(std::string_view s, std::size_t pos, std::size_t count = std::string_view::npos); + +// Single-code-point case folding (ASCII + Russian Cyrillic incl. Yo); other values pass through. +char32_t to_lower(char32_t cp); +char32_t to_upper(char32_t cp); + +// Whole-string case folding. Malformed bytes are copied through verbatim. +std::string to_lower(std::string_view s); +std::string to_upper(std::string_view s); + +} // namespace utf8 + +#endif // BYLINS_SRC_UTILS_UTF8_H_ + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/utils/utils.cpp b/src/utils/utils.cpp index 5a10b9bc70..b0fe3ae326 100644 --- a/src/utils/utils.cpp +++ b/src/utils/utils.cpp @@ -13,6 +13,7 @@ ************************************************************************ */ #include "utils.h" +#include "native_text.h" #include "utils/grammar/declensions.h" #include @@ -89,7 +90,7 @@ return result; -// str_cmp, strn_cmp moved to utils_string.cpp +// str_cmp moved to utils_string.cpp // the "touch" command, essentially. int touch(const char *path) { @@ -265,7 +266,7 @@ void format_text(const utils::AbstractStringWriter::shared_ptr &writer, flow++; } - if ((total_chars + (flow - start) + 1) > 79) { + if ((total_chars + native_text::char_count(start, flow) + 1) > 79) { strcpy(pos, "\r\n"); total_chars = 0; pos += 2; @@ -279,11 +280,11 @@ void format_text(const utils::AbstractStringWriter::shared_ptr &writer, } } - total_chars += flow - start; + total_chars += native_text::char_count(start, flow); strncpy(pos, start, flow - start); if (cap_next) { cap_next = false; - *pos = UPPER(*pos); + native_text::capitalize_first(pos); } pos += flow - start; } @@ -303,17 +304,19 @@ void format_text(const utils::AbstractStringWriter::shared_ptr &writer, strcpy(pos, "\r\n"); if (static_cast(pos - formatted) > maxlen) { - formatted[maxlen] = '\0'; + formatted[native_text::truncate_offset(formatted, maxlen)] = '\0'; } writer->set_string(formatted); } char *rustime(const struct tm *timeptr) { - static char mon_name[12][10] = + // Массив указателей, а не char[12][10]: ширина ячейки зависела бы от кодировки + // (в UTF-8 русская буква занимает два байта, и названия перестают влезать). + static const char *const mon_name[12] = { - "Января\0", "Февраля\0", "Марта\0", "Апреля\0", "Мая\0", "Июня\0", - "Июля\0", "Августа\0", "Сентября\0", "Октября\0", "Ноября\0", "Декабря\0" + "Января", "Февраля", "Марта", "Апреля", "Мая", "Июня", + "Июля", "Августа", "Сентября", "Октября", "Ноября", "Декабря" }; static char result[100]; diff --git a/src/utils/utils.h b/src/utils/utils.h index 7466718900..f1dca70e7d 100644 --- a/src/utils/utils.h +++ b/src/utils/utils.h @@ -557,72 +557,6 @@ inline char a_lcc(const unsigned char c) { return a_lcc_table[c]; } -enum separator_mode { - A_ISSPACE, - A_ISASCII, - A_ISPRINT, - A_ISLOWER, - A_ISUPPER, - A_ISDIGIT, - A_ISALPHA, - A_ISALNUM, - A_ISXDIGIT -}; - -class pred_separator { - bool (*pred)(unsigned char); - bool l_not; - public: - explicit - pred_separator(separator_mode _mode, bool _l_not = false) : l_not(_l_not) { - switch (_mode) { - case A_ISSPACE: pred = a_isspace; - break; - case A_ISASCII: pred = a_isascii; - break; - case A_ISPRINT: pred = a_isprint; - break; - case A_ISLOWER: pred = a_islower; - break; - case A_ISUPPER: pred = a_isupper; - break; - case A_ISDIGIT: pred = a_isdigit; - break; - case A_ISALPHA: pred = a_isalpha; - break; - case A_ISALNUM: pred = a_isalnum; - break; - case A_ISXDIGIT: pred = a_isxdigit; - break; - } - } - - explicit - pred_separator() : pred(a_isspace), l_not(false) {} - - void reset() {} - - bool operator()(std::string::const_iterator &next, std::string::const_iterator end, std::string &tok) { - tok = std::string(); - - if (l_not) - for (; next != end && !pred(*next); ++next) {} - else - for (; next != end && pred(*next); ++next) {} - - if (next == end) - return false; - - if (l_not) - for (; next != end && pred(*next); ++next) - tok += *next; - else - for (; next != end && !pred(*next); ++next) - tok += *next; - - return true; - } -}; // ВЕЯРМН ЯЙНОХОЮЯРЕМН template diff --git a/src/utils/utils_encoding.cpp b/src/utils/utils_encoding.cpp index 5d25112cb5..e842d685fc 100644 --- a/src/utils/utils_encoding.cpp +++ b/src/utils/utils_encoding.cpp @@ -16,23 +16,70 @@ namespace codepages { +// Tables of raw bytes, spelled as \xNN escapes on purpose. They used to be written as string +// literals of high-byte characters, which made their contents depend on the encoding of this +// file: with UTF-8 sources every Cyrillic/pseudographic character became two or three bytes and +// the tables silently grew past 128 entries, corrupting every legacy client's output. Escapes +// are plain ASCII and mean the same thing in any source encoding (issue #3681). char AltToKoi[] = { - "АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмноп░▒▓│┤╡+++╣║╗╝+╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨++╙╘╒++╪┘┌█▄▌▐▀рстуфхцчшщъыьэюяЁё╫╜╢╓╤╕╥╖·√??■ " + "\xE1\xE2\xF7\xE7\xE4\xE5\xF6\xFA\xE9\xEA\xEB\xEC\xED\xEE\xEF\xF0" + "\xF2\xF3\xF4\xF5\xE6\xE8\xE3\xFE\xFB\xFD\xFF\xF9\xF8\xFC\xE0\xF1" + "\xC1\xC2\xD7\xC7\xC4\xC5\xD6\xDA\xC9\xCA\xCB\xCC\xCD\xCE\xCF\xD0" + "\x90\x91\x92\x81\x87\xB2\x2B\x2B\x2B\xB5\xA1\xA8\xAE\x2B\xAC\x83" + "\x84\x89\x88\x86\x80\x8A\xAF\xB0\xAB\xA5\xBB\xB8\xB1\xA0\xBE\xB9" + "\xBA\x2B\x2B\xAA\xA9\xA2\x2B\x2B\xBC\x85\x82\x8D\x8C\x8E\x8F\x8B" + "\xD2\xD3\xD4\xD5\xC6\xC8\xC3\xDE\xDB\xDD\xDF\xD9\xD8\xDC\xC0\xD1" + "\xB3\xA3\xBD\xAD\xB4\xA4\xB6\xA6\xB7\xA7\x9E\x96\x3F\x3F\x94\x9A" }; char KoiToAlt[] = { - "дЁз©юыц╢баеъэшщч╟╠╡+Ч+Ш+++Ъ+++З+м╨уЯУиВЫ╩тсх╬С╪фгл╣ПТ╧ЖЬкопйьРн+Н═║Ф╓╔ДёЕ╗╘╙╚╛╜╝╞ОЮАБЦ╕╒ЛК╖ХМИГЙ·─│√└┘■┐∙┬┴┼▀▄█▌▐÷░▒▓⌠├┌°⌡┤≤²≥≈ " + "\xC4\xB3\xDA\xBF\xC0\xD9\xC3\xB4\xC2\xC1\xC5\xDF\xDC\xDB\xDD\xDE" + "\xB0\xB1\xB2\x2B\xFE\x2B\xFB\x2B\x2B\x2B\xFF\x2B\x2B\x2B\xFA\x2B" + "\xCD\xBA\xD5\xF1\xF5\xC9\xF7\xF9\xBB\xD4\xD3\xC8\xBE\xF3\xBC\xC6" + "\xC7\xCC\xB5\xF0\xF4\xB9\xF6\xF8\xCB\xCF\xD0\xCA\xD8\xF2\xCE\x2B" + "\xEE\xA0\xA1\xE6\xA4\xA5\xE4\xA3\xE5\xA8\xA9\xAA\xAB\xAC\xAD\xAE" + "\xAF\xEF\xE0\xE1\xE2\xE3\xA6\xA2\xEC\xEB\xA7\xE8\xED\xE9\xE7\xEA" + "\x9E\x80\x81\x96\x84\x85\x94\x83\x95\x88\x89\x8A\x8B\x8C\x8D\x8E" + "\x8F\x9F\x90\x91\x92\x93\x86\x82\x9C\x9B\x87\x98\x9D\x99\x97\x9A" }; char WinToKoi[] = { - "++++++++++++++++++++++++++++++++ ++++╫++Ё©╢++++╥°+╤╕╜++·ё+╓++++╖АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыьэюя" + "\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B" + "\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B" + "\x9A\x2B\x2B\x2B\x2B\xBD\x2B\x2B\xB3\xBF\xB4\x2B\x2B\x2B\x2B\xB7" + "\x9C\x2B\xB6\xA6\xAD\x2B\x2B\x9E\xA3\x2B\xA4\x2B\x2B\x2B\x2B\xA7" + "\xE1\xE2\xF7\xE7\xE4\xE5\xF6\xFA\xE9\xEA\xEB\xEC\xED\xEE\xEF\xF0" + "\xF2\xF3\xF4\xF5\xE6\xE8\xE3\xFE\xFB\xFD\xFF\xF9\xF8\xFC\xE0\xF1" + "\xC1\xC2\xD7\xC7\xC4\xC5\xD6\xDA\xC9\xCA\xCB\xCC\xCD\xCE\xCF\xD0" + "\xD2\xD3\xD4\xD5\xC6\xC8\xC3\xDE\xDB\xDD\xDF\xD9\xD8\xDC\xC0\xD1" }; char KoiToWin[] = { - "++++++++++++++++++++++++++═+╟+╥++++╦╨+Ё©+++++╢+++++╗╙+╡╞+++++╔+╘ЧЮАЖДЕТЦУХИЙКЛМНОЪПЯРСФБЭШГЬЩЫВЗчюаждетцухийклмноъпярсфбэшгьщывз" + "\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B" + "\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\xA0\x2B\xB0\x2B\xB7\x2B" + "\x2B\x2B\x2B\xB8\xBA\x2B\xB3\xBF\x2B\x2B\x2B\x2B\x2B\xB4\x2B\x2B" + "\x2B\x2B\x2B\xA8\xAA\x2B\xB2\xAF\x2B\x2B\x2B\x2B\x2B\xA5\x2B\xA9" + "\xFE\xE0\xE1\xF6\xE4\xE5\xF4\xE3\xF5\xE8\xE9\xEA\xEB\xEC\xED\xEE" + "\xEF\xFF\xF0\xF1\xF2\xF3\xE6\xE2\xFC\xFB\xE7\xF8\xFD\xF9\xF7\xFA" + "\xDE\xC0\xC1\xD6\xC4\xC5\xD4\xC3\xD5\xC8\xC9\xCA\xCB\xCC\xCD\xCE" + "\xCF\xDF\xD0\xD1\xD2\xD3\xC6\xC2\xDC\xDB\xC7\xD8\xDD\xD9\xD7\xDA" }; char KoiToWin2[] = { - "++++++++++++++++++++++++++═+╟+╥++++╦╨+Ё©+++++╢+++++╗╙+╡╞+++++╔+╘ЧЮАЖДЕТЦУХИЙКЛМНОzПЯРСФБЭШГЬЩЫВЗчюаждетцухийклмноъпярсфбэшгьщывз" + "\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B" + "\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\x2B\xA0\x2B\xB0\x2B\xB7\x2B" + "\x2B\x2B\x2B\xB8\xBA\x2B\xB3\xBF\x2B\x2B\x2B\x2B\x2B\xB4\x2B\x2B" + "\x2B\x2B\x2B\xA8\xAA\x2B\xB2\xAF\x2B\x2B\x2B\x2B\x2B\xA5\x2B\xA9" + "\xFE\xE0\xE1\xF6\xE4\xE5\xF4\xE3\xF5\xE8\xE9\xEA\xEB\xEC\xED\xEE" + "\xEF\x7A\xF0\xF1\xF2\xF3\xE6\xE2\xFC\xFB\xE7\xF8\xFD\xF9\xF7\xFA" + "\xDE\xC0\xC1\xD6\xC4\xC5\xD4\xC3\xD5\xC8\xC9\xCA\xCB\xCC\xCD\xCE" + "\xCF\xDF\xD0\xD1\xD2\xD3\xC6\xC2\xDC\xDB\xC7\xD8\xDD\xD9\xD7\xDA" }; char AltToLat[] = { - "─│┌┐└┘├┤┬┴┼▀▄█▌▐░▒▓⌠■∙√≈≤≥ ⌡°²·÷═║╒ё╓╔╕╖╗╘╙╚╛╜╝╞╟╠╡Ё╢╣╤╥╦╧╨╩╪╫╬©0abcdefghijklmnopqrstY1v23z456780ABCDEFGHIJKLMNOPQRSTY1V23Z45678" + "\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8A\x8B\x8C\x8D\x8E\x8F" + "\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9A\x9B\x9C\x9D\x9E\x9F" + "\xA0\xA1\xA2\xA3\xA4\xA5\xA6\xA7\xA8\xA9\xAA\xAB\xAC\xAD\xAE\xAF" + "\xB0\xB1\xB2\xB3\xB4\xB5\xB6\xB7\xB8\xB9\xBA\xBB\xBC\xBD\xBE\xBF" + "\x30\x61\x62\x63\x64\x65\x66\x67\x68\x69\x6A\x6B\x6C\x6D\x6E\x6F" + "\x70\x71\x72\x73\x74\x59\x31\x76\x32\x33\x7A\x34\x35\x36\x37\x38" + "\x30\x41\x42\x43\x44\x45\x46\x47\x48\x49\x4A\x4B\x4C\x4D\x4E\x4F" + "\x50\x51\x52\x53\x54\x59\x31\x56\x32\x33\x5A\x34\x35\x36\x37\x38" }; void koi_to_win(char *str, int size) { @@ -103,7 +150,10 @@ void utf8_to_koi(char *str_i, char *str_o) #else // HAVE_ICONV -#define KOI8_UNKNOWN_CHAR '+' // char to use when cannot represent unicode codepoint in KOI8-R +// Placeholder for a code point KOI8-R cannot represent AND for which translit_koi8 has no +// equivalent. '?' rather than the former '+': a plus is ordinary text in this game ("+5 to +// damage"), so it read as content rather than as a lost character (issue #3681). +#define KOI8_UNKNOWN_CHAR '?' // Simple implementation of UTF-8/KOI8-R converter, supports all codes available in KOI8-R void utf8_to_koi(char *str_i, char *str_o) { diff --git a/src/utils/utils_parse.cpp b/src/utils/utils_parse.cpp index 0105e2bd1d..8aacfbbaa4 100644 --- a/src/utils/utils_parse.cpp +++ b/src/utils/utils_parse.cpp @@ -4,12 +4,13 @@ #include "utils_parse.h" #include "third_party_libs/pugixml/pugixml.h" -#include "utils/parser_wrapper.h" // issue.xml-parse-cleaning: AttrInt/AttrStr over DataNode +#include "utils/parser_wrapper.h" +#include "utils/native_text.h" // issue.xml-parse-cleaning: AttrInt/AttrStr over DataNode #include "engine/db/obj_prototypes.h" #include "engine/db/db.h" #include "utils/utils.h" // a_isdigit -#include "utils/utils_string.h" // str_cmp/strn_cmp (search_block) +#include "utils/utils_string.h" // str_cmp/IsAbbr (search_block) //extern ObjRnum GetObjRnum(ObjVnum vnum) { return obj_proto.rnum(vnum); @@ -351,6 +352,11 @@ int AttrInt(const parser_wrapper::DataNode &node, const char *key, int def) { std::string AttrStr(const parser_wrapper::DataNode &node, const char *key, const char *def) { const char *v = node.GetValue(key); + // Перекодировки здесь НЕТ и быть не должно: DataNode приводит содержимое файла к нативной + // кодировке один раз на документ, поэтому значение уже нативное. Пока перекодировка стояла + // ещё и здесь, каждое поле переводилось дважды, а так как конфиги движок ещё и пишет + // обратно, файл рос на каждой загрузке -- cfg/mechanics/obj_sets.xml так дорос со 120 КБ + // до 6,7 ГБ и убивал загрузку по памяти (issue #3681). return (v && *v) ? std::string(v) : std::string(def); } @@ -393,10 +399,21 @@ int get_number(std::string &name) { // issue.handler-cleaning: first keyword of a name list (moved from handler). char *fname(const char *namelist) { static char holder[30]; - char *point; - - for (point = holder; a_isalpha(*namelist); namelist++, point++) - *point = *namelist; + char *point = holder; + + // Copy the leading word one whole character at a time (issue #3681): a byte-wise copy stops + // in the middle of a multibyte letter. The bounds check is new -- the previous loop could + // already run past holder[] on a long keyword, and multibyte text reaches the end twice as + // fast, so leave room for the terminator. + while (native_text::is_alpha_char(namelist)) { + const size_t bytes = native_text::char_bytes(namelist); + if (point + bytes >= holder + sizeof(holder)) { + break; + } + for (size_t i = 0; i < bytes; ++i) { + *point++ = *namelist++; + } + } *point = '\0'; @@ -406,7 +423,6 @@ char *fname(const char *namelist) { // issue.interpreter-cleaning: generic argument/token parsing helpers moved from interpreter.cpp. int search_block(const char *target_string, const char **list, int exact) { int i; - size_t l = strlen(target_string); if (exact) { for (i = 0; **(list + i) != '\n'; i++) { @@ -415,11 +431,8 @@ int search_block(const char *target_string, const char **list, int exact) { } } } else { - if (0 == l) { - l = 1; // Avoid "" to match the first available string - } for (i = 0; **(list + i) != '\n'; i++) { - if (!strn_cmp(target_string, *(list + i), l)) { + if (utils::IsAbbr(target_string, *(list + i))) { return i; } } @@ -430,17 +443,14 @@ int search_block(const char *target_string, const char **list, int exact) { int search_block(const std::string &block, const char **list, int exact) { int i; - std::string::size_type l = block.length(); if (exact) { for (i = 0; **(list + i) != '\n'; i++) if (!str_cmp(block, *(list + i))) return (i); } else { - if (!l) - l = 1; // Avoid "" to match the first available string for (i = 0; **(list + i) != '\n'; i++) - if (!strn_cmp(block, *(list + i), l)) + if (utils::IsAbbr(block, *(list + i))) return (i); } @@ -488,11 +498,16 @@ bool CompareParam(const std::string &buffer, const char *str, bool full) { return false; } + // Посимвольно, а не побайтово: под UTF-8 регистр русской буквы по одному байту не берётся + // (issue #3681). Шаг обеих строк -- на длину символа, они здесь всегда в одной кодировке. std::string::size_type i; - for (i = 0; i != buffer.length() && *str; ++i, ++str) { - if (LOWER(buffer[i]) != LOWER(*str)) { + for (i = 0; i != buffer.length() && *str;) { + if (!native_text::chars_equal_ci(buffer.c_str() + i, str)) { return false; } + const std::size_t step = native_text::char_bytes(str); + i += step; + str += step; } if (i == buffer.length()) { @@ -509,11 +524,13 @@ bool CompareParam(const std::string &buffer, const std::string &buffer2, bool fu return false; } + // Посимвольно, как и в перегрузке выше (issue #3681). std::string::size_type i; - for (i = 0; i != buffer.length() && i != buffer2.length(); ++i) { - if (LOWER(buffer[i]) != LOWER(buffer2[i])) { + for (i = 0; i != buffer.length() && i != buffer2.length();) { + if (!native_text::chars_equal_ci(buffer.c_str() + i, buffer2.c_str() + i)) { return false; } + i += native_text::char_bytes(buffer.c_str() + i); } if (i == buffer.length()) { diff --git a/src/utils/utils_string.cpp b/src/utils/utils_string.cpp index 496d355d2d..94ed80c980 100644 --- a/src/utils/utils_string.cpp +++ b/src/utils/utils_string.cpp @@ -1,6 +1,9 @@ //#include "utils_string.h" +#include + #include "utils.h" +#include "utils/native_text.h" #include "utils/utils_encoding.h" #include "gameplay/core/constants.h" @@ -111,15 +114,35 @@ void DelegatedStringWriter::clear() { m_delegated_string_ = nullptr; } +bool IsSamePrefix(const char *arg1, const char *arg2, std::size_t chars) { + if (arg1 == nullptr || arg2 == nullptr) { + return false; + } + for (std::size_t i = 0; i < chars; ++i) { + if (!*arg1 || !*arg2) { + return false; // символы кончились раньше, чем набралось chars + } + if (!native_text::chars_equal_ci(arg1, arg2)) { + return false; + } + arg1 += native_text::char_bytes(arg1); + arg2 += native_text::char_bytes(arg2); + } + return true; +} + bool IsAbbr(const char *arg1, const char *arg2) { if (!*arg1) { return false; } - for (; *arg1 && *arg2; arg1++, arg2++) { - if (LOWER(*arg1) != LOWER(*arg2)) { + // Посимвольно (issue #3681): побайтное сравнение под UTF-8 теряет регистронезависимость. + while (*arg1 && *arg2) { + if (!native_text::chars_equal_ci(arg1, arg2)) { return false; } + arg1 += native_text::char_bytes(arg1); + arg2 += native_text::char_bytes(arg2); } if (!*arg1) { @@ -240,9 +263,7 @@ std::string ExtractFirstArgument(const std::string &s, std::string &remains) { } std::string SubstToLow(std::string s) { - for (char &it: s) { - it = LOWER(it); - } + ConvertToLow(s); return s; } @@ -269,29 +290,22 @@ std::string SubstWtoK(std::string s) { } void ConvertToLow(std::string &text) { - for (char &it: text) { - it = LOWER(it); - } + native_text::to_lower(text); } void ConvertToLow(char *text) { - while (*text) { - *text = LOWER(*text); - text++; - } + native_text::to_lower(text); } std::string SubstStrToLow(std::string s) { - for (char &it: s) { - it = UPPER(it); - } + // NB: имя говорит "ToLow", а тело поднимает регистр. Расхождение предсуществующее, + // поведение сохранено намеренно -- меняется только байтовая семантика на символьную. + native_text::to_upper(s); return s; } std::string SubstStrToUpper(std::string s) { - for (char &it: s) { - it = UPPER(it); - } + native_text::to_upper(s); return s; } @@ -382,25 +396,21 @@ std::string FixDot(std::string s) { return s; } +// Сортировка идёт по ключу, а не по перекодированной на месте строке. Прежний трюк -- перегнать +// список в Windows-1251 (там русские буквы стоят по алфавиту, в отличие от KOI8-R), отсортировать +// и перегнать обратно -- под UTF-8 портил текст: побайтовая таблица KOI8-R -> Windows-1251 для +// многобайтовой строки не значит ничего, и обратное преобразование уже не восстанавливало исходник +// (issue #3681). native_text::sort_key даёт ровно тот же порядок, ничего не меняя в самих строках. void SortKoiString(std::vector &str) { - for (auto &it : str) { - ConvertKtoW(it); - } - std::sort(str.begin(), str.end(), std::less()); - for (auto &it : str) { - ConvertWtoK(it); - } + std::sort(str.begin(), str.end(), [](const std::string &a, const std::string &b) { + return native_text::sort_key(a) < native_text::sort_key(b); + }); } - void SortKoiStringReverse(std::vector &str) { - for (auto &it : str) { - ConvertKtoW(it); - } - std::sort(str.begin(), str.end(), std::greater()); - for (auto &it : str) { - ConvertWtoK(it); - } + std::sort(str.begin(), str.end(), [](const std::string &a, const std::string &b) { + return native_text::sort_key(a) > native_text::sort_key(b); + }); } void ReplaceFirst(std::string &s, const std::string &toSearch, const std::string &replacer) { @@ -488,14 +498,14 @@ const char *first_letter(const char *txt) { char *colorCAP(char *txt) { char *letter = const_cast(first_letter(txt)); if (letter && *letter) { - *letter = UPPER(*letter); + native_text::capitalize_first(letter); } return txt; } std::string &colorCAP(std::string &txt) { size_t pos = first_letter(txt.c_str()) - txt.c_str(); - txt[pos] = UPPER(txt[pos]); + native_text::capitalize_first(&txt[pos]); return txt; } @@ -507,14 +517,14 @@ std::string &colorCAP(std::string &&txt) { char *colorLOW(char *txt) { char *letter = const_cast(first_letter(txt)); if (letter && *letter) { - *letter = LOWER(*letter); + native_text::copy_lower_char(letter, letter); } return txt; } std::string &colorLOW(std::string &txt) { size_t pos = first_letter(txt.c_str()) - txt.c_str(); - txt[pos] = LOWER(txt[pos]); + native_text::copy_lower_char(&txt[pos], &txt[pos]); return txt; } @@ -524,13 +534,13 @@ std::string &colorLOW(std::string &&txt) { } char *CAP(char *txt) { - *txt = UPPER(*txt); + native_text::capitalize_first(txt); return (txt); } std::string CAP(const std::string txt) { std::string tmp_str = txt; - tmp_str[0] = UPPER(tmp_str[0]); + native_text::capitalize_first(tmp_str); return (tmp_str); } @@ -591,129 +601,37 @@ char *delete_doubledollar(char *string) { } // Moved from utils.cpp +// The str_cmp family folds case per *character*: under KOI8-R that is the original +// byte-wise LOWER() loop kept verbatim below, under UTF-8 it is native_text's code-point fold +// (issue #3681). The KOI8-R path is untouched so behaviour is bit-identical until the flip. int str_cmp(const char *arg1, const char *arg2) { - int chk, i; if (arg1 == nullptr || arg2 == nullptr) { log("SYSERR: str_cmp() passed a nullptr pointer, %p or %p.", arg1, arg2); return (0); } - for (i = 0; arg1[i] || arg2[i]; i++) - if ((chk = LOWER(arg1[i]) - LOWER(arg2[i])) != 0) - return (chk); - return (0); + return native_text::compare_ci(arg1, arg2); } int str_cmp(const std::string &arg1, const char *arg2) { - int chk; - std::string::size_type i; if (arg2 == nullptr) { log("SYSERR: str_cmp() passed a NULL pointer, %p.", arg2); return (0); } - for (i = 0; i != arg1.length() && *arg2; i++, arg2++) - if ((chk = LOWER(arg1[i]) - LOWER(*arg2)) != 0) - return (chk); - if (i == arg1.length() && !*arg2) - return (0); - if (*arg2) - return (LOWER('\0') - LOWER(*arg2)); - else - return (LOWER(arg1[i]) - LOWER('\0')); + return native_text::compare_ci(arg1, arg2); } int str_cmp(const char *arg1, const std::string &arg2) { - int chk; - std::string::size_type i; if (arg1 == nullptr) { log("SYSERR: str_cmp() passed a NULL pointer, %p.", arg1); return (0); } - for (i = 0; *arg1 && i != arg2.length(); i++, arg1++) - if ((chk = LOWER(*arg1) - LOWER(arg2[i])) != 0) - return (chk); - if (!*arg1 && i == arg2.length()) - return (0); - if (*arg1) - return (LOWER(*arg1) - LOWER('\0')); - else - return (LOWER('\0') - LOWER(arg2[i])); + return native_text::compare_ci(arg1, arg2); } int str_cmp(const std::string &arg1, const std::string &arg2) { - int chk; - std::string::size_type i; - for (i = 0; i != arg1.length() && i != arg2.length(); i++) - if ((chk = LOWER(arg1[i]) - LOWER(arg2[i])) != 0) - return (chk); - if (arg1.length() == arg2.length()) - return (0); - if (i == arg1.length()) - return (LOWER('\0') - LOWER(arg2[i])); - else - return (LOWER(arg1[i]) - LOWER('\0')); -} - -int strn_cmp(const char *arg1, const char *arg2, size_t n) { - int chk, i; - if (arg1 == nullptr || arg2 == nullptr) { - log("SYSERR: strn_cmp() passed a NULL pointer, %p or %p.", arg1, arg2); - return (0); - } - for (i = 0; (arg1[i] || arg2[i]) && (n > 0); i++, n--) - if ((chk = LOWER(arg1[i]) - LOWER(arg2[i])) != 0) - return (chk); - return (0); -} - -int strn_cmp(const std::string &arg1, const char *arg2, size_t n) { - int chk; - std::string::size_type i; - if (arg2 == nullptr) { - log("SYSERR: strn_cmp() passed a NULL pointer, %p.", arg2); - return (0); - } - for (i = 0; i != arg1.length() && *arg2 && (n > 0); i++, arg2++, n--) - if ((chk = LOWER(arg1[i]) - LOWER(*arg2)) != 0) - return (chk); - if (i == arg1.length() && (!*arg2 || n == 0)) - return (0); - if (*arg2) - return (LOWER('\0') - LOWER(*arg2)); - else - return (LOWER(arg1[i]) - LOWER('\0')); + return native_text::compare_ci(arg1, arg2); } -int strn_cmp(const char *arg1, const std::string &arg2, size_t n) { - int chk; - std::string::size_type i; - if (arg1 == nullptr) { - log("SYSERR: strn_cmp() passed a NULL pointer, %p.", arg1); - return (0); - } - for (i = 0; *arg1 && i != arg2.length() && (n > 0); i++, arg1++, n--) - if ((chk = LOWER(*arg1) - LOWER(arg2[i])) != 0) - return (chk); - if (!*arg1 && (i == arg2.length() || n == 0)) - return (0); - if (*arg1) - return (LOWER(*arg1) - LOWER('\0')); - else - return (LOWER('\0') - LOWER(arg2[i])); -} - -int strn_cmp(const std::string &arg1, const std::string &arg2, size_t n) { - int chk; - std::string::size_type i; - for (i = 0; i != arg1.length() && i != arg2.length() && (n > 0); i++, n--) - if ((chk = LOWER(arg1[i]) - LOWER(arg2[i])) != 0) - return (chk); - if (arg1.length() == arg2.length() || (n == 0)) - return (0); - if (i == arg1.length()) - return (LOWER('\0') - LOWER(arg2[i])); - else - return (LOWER(arg1[i]) - LOWER('\0')); -} void StringReplace(std::string &buffer, char s, const std::string &d) { for (size_t index = 0; index = buffer.find(s, index), index != std::string::npos;) { @@ -824,15 +742,18 @@ char *str_str(const char *cs, const char *ct) { if (!cs || !ct) { return nullptr; } + // Сравниваем и шагаем ПО СИМВОЛАМ: под UTF-8 побайтовое приведение регистра для кириллицы + // неверно (таблица регистра -- байтовая, KOI8-R), и поиск подстроки то не находил совпадения, + // то находил ложные на границе байтов (issue #3681). while (*cs) { const char *t = ct; - while (*cs && (LOWER(*cs) != LOWER(*t))) { - cs++; + while (*cs && !native_text::chars_equal_ci(cs, t)) { + cs += native_text::char_bytes(cs); } char *s = (char*)cs; - while (*t && *cs && (LOWER(*cs) == LOWER(*t))) { - t++; - cs++; + while (*t && *cs && native_text::chars_equal_ci(cs, t)) { + t += native_text::char_bytes(t); + cs += native_text::char_bytes(cs); } if (!*t) { return s; @@ -862,13 +783,15 @@ void cut_one_word(std::string &str, std::string &word) { } bool process = false; unsigned begin = 0, end = 0; - for (unsigned i = 0; i < str.size(); ++i) { - if (!process && a_isalnum(str.at(i))) { + // Word boundaries are looked for one whole character at a time (issue #3681); a byte-wise + // scan finds a "boundary" inside a multibyte letter and cuts the word in half. + for (unsigned i = 0; i < str.size(); i += native_text::char_bytes(str.c_str() + i)) { + if (!process && native_text::is_alnum_char(str.c_str() + i)) { process = true; begin = i; continue; } - if (process && !a_isalnum(str.at(i))) { + if (process && !native_text::is_alnum_char(str.c_str() + i)) { end = i; break; } @@ -927,13 +850,18 @@ bool IsValidEmail(const char *address) { return true; } +// Walks both strings one *character* at a time (issue #3681): the classification, the +// case-insensitive match and every advance go through native_text, so a multibyte letter is one +// unit instead of a lead byte plus trail bytes that the byte tables would read as punctuation. +// Under KOI8-R every helper is the original byte operation and char_bytes() == 1, so the state +// machine below -- including each `curstr = laststr` backtrack -- behaves exactly as before. bool isname(const char *str, const char *namelist) { bool once_ok = false; const char *curname, *curstr, *laststr; if (!namelist || !*namelist || !str) { return false; } - for (curstr = str; !a_isalnum(*curstr); curstr++) { + for (curstr = str; !native_text::is_alnum_char(curstr); curstr += native_text::char_bytes(curstr)) { if (!*curstr) { return once_ok; } @@ -942,18 +870,18 @@ bool isname(const char *str, const char *namelist) { curname = namelist; for (;;) { once_ok = false; - for (;; curstr++, curname++) { + for (;; curstr += native_text::char_bytes(curstr), curname += native_text::char_bytes(curname)) { if (!*curstr) { return once_ok; } if (*curstr == '!') { - if (a_isalnum(*curname)) { + if (native_text::is_alnum_char(curname)) { curstr = laststr; break; } } - if (!a_isalnum(*curstr)) { - for (; !a_isalnum(*curstr); curstr++) { + if (!native_text::is_alnum_char(curstr)) { + for (; !native_text::is_alnum_char(curstr); curstr += native_text::char_bytes(curstr)) { if (!*curstr) { return once_ok; } @@ -964,19 +892,19 @@ bool isname(const char *str, const char *namelist) { if (!*curname) { return false; } - if (!a_isalnum(*curname)) { + if (!native_text::is_alnum_char(curname)) { curstr = laststr; break; } - if (LOWER(*curstr) != LOWER(*curname)) { + if (!native_text::chars_equal_ci(curstr, curname)) { curstr = laststr; break; } else { once_ok = true; } } - for (; a_isalnum(*curname); curname++); - for (; !a_isalnum(*curname); curname++) { + for (; native_text::is_alnum_char(curname); curname += native_text::char_bytes(curname)); + for (; !native_text::is_alnum_char(curname); curname += native_text::char_bytes(curname)) { if (!*curname) { return false; } @@ -988,17 +916,21 @@ const char *one_word(const char *argument, char *first_arg) { char *begin = first_arg; skip_spaces(&argument); first_arg = begin; + // Lowercase whole characters (issue #3681); the '"' and a_isspace() tests stay byte-based + // since they only run at a character boundary and both delimiters are ASCII. if (*argument == '\"') { argument++; while (*argument && *argument != '\"') { - *(first_arg++) = a_lcc(*argument); - argument++; + const size_t n = native_text::copy_lower_char(argument, first_arg); + first_arg += n; + argument += n; } argument++; } else { while (*argument && !a_isspace(*argument)) { - *(first_arg++) = a_lcc(*argument); - argument++; + const size_t n = native_text::copy_lower_char(argument, first_arg); + first_arg += n; + argument += n; } } *first_arg = '\0'; @@ -1064,7 +996,10 @@ std::string utils::OutWordsList(const std::vector &words, size_t ma // склеивается через separator (first остаётся true -- первое слово идёт // сразу за префиксом без ", "). Видимую длину считаем без цветокодов. std::string result = prefix; - size_t line_length = GetStringWithoutColors(prefix).size(); + // Ширина -- в символах, а не в байтах: в UTF-8 русская буква занимает два, и счёт по + // size() рвал бы строку вдвое раньше запрошенного (issue #3681). + size_t line_length = native_text::char_count(GetStringWithoutColors(prefix)); + const size_t separator_len = native_text::char_count(separator); bool first = true; // separator -- это и есть то, что стоит между словами на одной строке // (", " для списка, " " для обычного переноса по словам). На переносе @@ -1078,14 +1013,14 @@ std::string utils::OutWordsList(const std::vector &words, size_t ma for (const auto &word : words) { // ширину считаем по видимой длине -- цветокоды (&R, &n и т.п.) на экране // места не занимают, иначе строки с цветом переносятся раньше времени - const size_t word_len = GetStringWithoutColors(word).size(); + const size_t word_len = native_text::char_count(GetStringWithoutColors(word)); if (!first) { - if (line_length + separator.size() + word_len > max_length) { + if (line_length + separator_len + word_len > max_length) { result += eol_separator + "\r\n"; line_length = 0; } else { result += separator; - line_length += separator.size(); + line_length += separator_len; } } result += word; @@ -1144,6 +1079,14 @@ std::string sprintGender(int gender_value) { // замена в name русских символов на англ в нижнем регистре (для файлов) void CreateFileName(std::string &name) { + // Имя файла собирается по байтам KOI8-R -- ровно так, как собиралось до миграции. Иначе у + // уже существующего персонажа доска получит другое имя файла, и старая просто потеряется: + // под UTF-8 байтовый AtoL резал русскую букву пополам и оставлял её в имени как есть, отчего + // рядом со 105 старыми досками завелось 70 новых, с кириллицей в имени (issue #3681). + // + // Приводим строку к KOI8-R и дальше работаем прежним байтовым кодом: под KOI8-R to_koi8 -- + // тождество, так что поведение там не меняется ни на байт. + name = native_text::to_koi8(name); for (unsigned i = 0; i != name.length(); ++i) name[i] = LOWER(codepages::AtoL(name[i])); } @@ -1169,7 +1112,10 @@ std::string ExpFormat(long long exp) { void name_convert(std::string &text) { if (!text.empty()) { utils::ConvertToLow(text); - *text.begin() = UPPER(*text.begin()); + // Первую букву поднимаем целиком, а не первый байт: под UTF-8 у русской буквы их два, + // и байтовый UPPER превращал имя в мусор -- "имя щщщщщ одобрить" не находило + // персонажа (issue #3681). + native_text::capitalize_first(text); } } diff --git a/src/utils/utils_string.h b/src/utils/utils_string.h index e0285367c7..1071f7f579 100644 --- a/src/utils/utils_string.h +++ b/src/utils/utils_string.h @@ -112,6 +112,11 @@ std::string GetStringWithoutColors(const std::string &string); bool IsEquivalent(const std::string &abbr, const std::string &words); // поиск abbr без пропусков слов в строке words bool IsEqual(const std::string &abbr, const std::string &words); +// Совпадают ли первые chars символов у обеих строк (без учёта регистра). Если символов +// в какой-то из строк меньше -- не совпадают. Единица счёта -- символ, а не байт: пределы +// вроде kMinNameLength означают буквы, и под UTF-8 байтовый счёт давал вдвое короче (#3681). +bool IsSamePrefix(const char *arg1, const char *arg2, std::size_t chars); + // arg1 аббревиатура в arg2 тексте\фразе bool IsAbbr(const char *arg1, const char *arg2); inline int IsAbbr(const std::string &arg1, const char *arg2) { return IsAbbr(arg1.c_str(), arg2); } @@ -326,13 +331,6 @@ int str_cmp(const std::string &arg1, const char *arg2); int str_cmp(const char *arg1, const std::string &arg2); int str_cmp(const std::string &arg1, const std::string &arg2); -/// Сравнение строк без учета регистра с ограничением длины (аналог strncmp). -/// Возвращает: 0 если равны, >0 если arg1 > arg2, <0 если arg1 < arg2. -int strn_cmp(const char *arg1, const char *arg2, size_t n); -int strn_cmp(const std::string &arg1, const char *arg2, size_t n); -int strn_cmp(const char *arg1, const std::string &arg2, size_t n); -int strn_cmp(const std::string &arg1, const std::string &arg2, size_t n); - /// Удаление завершающих \r\n из C-строки. /// Дубль: utils::TrimRight - похожий функционал но для пробелов. void PruneCrlf(char *txt); diff --git a/src/version.cpp.in b/src/version.cpp.in index 0136dacf98..4aeab4323e 100644 --- a/src/version.cpp.in +++ b/src/version.cpp.in @@ -25,13 +25,15 @@ void ShowBuildInfo(CharData *ch) { SendMsgToChar(ch, "%s %s, build from %s, revision %s\n", engine_name, engine_version, build_datetime, revision); SendMsgToChar(ch, "Based on CircleMUD, version 3.00 beta patchlevel 16\n"); if (privilege::IsImmortal(ch)) { - SendMsgToChar(ch, "Compiler: %s\nEnabled features: %s\n", build_compiler, build_features); + SendMsgToChar(ch, "Compiler: %s; internal encoding: %s\nEnabled features: %s\n", + build_compiler, "UTF-8", build_features); } } void LogBuildInfo() { - log("%s %s, build from %s, revision: %s\r\nCompiler: %s\r\nEnabled features: %s", - engine_name, engine_version, build_datetime, revision, build_compiler, build_features); + log("%s %s, build from %s, revision: %s\r\nCompiler: %s; internal encoding: %s\r\nEnabled features: %s", + engine_name, engine_version, build_datetime, revision, + build_compiler, "UTF-8", build_features); } // vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/tests/boards.encoding.cpp b/tests/boards.encoding.cpp new file mode 100644 index 0000000000..0d32e01d95 --- /dev/null +++ b/tests/boards.encoding.cpp @@ -0,0 +1,73 @@ +// Загрузка доски с русским текстом: проверяет, что сообщения приходят в НАТИВНОЙ кодировке +// движка. Файл доски лежит на диске в KOI8-R и читается потоком, минуя FBFILE, поэтому это +// отдельная граница кодировки (issue #3681). Тест осмыслен в обеих сборках: литералы ниже +// компилируются в той же кодировке, в какой работает движок. + +#include "gameplay/communication/boards/boards_types.h" +#include "utils/native_text.h" +#include "utils/utf8.h" + +#include + +#include +#include + +namespace { + +const char *const kNewsSample = "data/boards/news.sample"; + +Boards::Board::shared_ptr LoadSampleBoard() { + auto board = std::make_shared(Boards::NEWS_BOARD); + board->set_file_name(kNewsSample); + board->Load(); + return board; +} + +} // namespace + +TEST(BoardsEncoding, MessagesLoadInTheNativeEncoding) { + std::ifstream probe(kNewsSample); + ASSERT_TRUE(probe.is_open()) << "нет тестовых данных " << kNewsSample; + probe.close(); + + const auto board = LoadSampleBoard(); + ASSERT_FALSE(board->empty()) << "доска не загрузилась"; + + bool seen_cyrillic = false; + for (std::size_t i = 0; i < board->messages_count(); ++i) { + const auto message = board->get_message(i); + for (const std::string *field : {&message->author, &message->subject, &message->text}) { + if (field->empty()) { + continue; + } + const bool has_high_byte = std::any_of(field->begin(), field->end(), + [](char c) { return static_cast(c) >= 0x80; }); + if (!has_high_byte) { + continue; + } + seen_cyrillic = true; + // В UTF-8-сборке текст обязан быть корректным UTF-8; в KOI8-R-сборке -- наоборот, + // это KOI8-R, который валидным UTF-8 не является. + EXPECT_TRUE(utf8::is_valid(*field)) + << "поле не в нативной кодировке: " << *field; + } + } + EXPECT_TRUE(seen_cyrillic) << "в тестовых данных не оказалось русского текста -- проверять нечего"; +} + +TEST(BoardsEncoding, AuthorNameSurvivesTheBoundary) { + const auto board = LoadSampleBoard(); + ASSERT_FALSE(board->empty()); + + // Автор одного из сообщений в news.sample -- "Стрибог". + bool found = false; + for (std::size_t i = 0; i < board->messages_count(); ++i) { + if (board->get_message(i)->author == "Стрибог") { + found = true; + break; + } + } + EXPECT_TRUE(found) << "имя автора не совпало -- текст доски пришёл не в той кодировке"; +} + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/tests/liquid.name_roundtrip.cpp b/tests/liquid.name_roundtrip.cpp new file mode 100644 index 0000000000..2dc86c55ba --- /dev/null +++ b/tests/liquid.name_roundtrip.cpp @@ -0,0 +1,88 @@ +// Название ёмкости: залил жидкость -- вылил -- имя вернулось прежним (issue #3681). +// +// Разделитель " с " между названием ёмкости и названием жидкости срезался жёсткой +// тройкой байт -- столько он занимает в KOI8-R. В UTF-8 буква "с" двухбайтовая, +// разделитель занимает четыре байта, срезалось три, и первый пробел оставался +// в названии: "древний череп с синим колдовским зельем". При следующей заливке +// к нему снова клеился разделитель, и пробелов становилось два. + +#include "gameplay/mechanics/liquid.h" +#include "engine/entities/obj_data.h" +#include "utils/grammar/cases.h" + +#include + +#include +#include + +namespace { + +constexpr ObjVnum kRoundTripJarVnum = 100702; +constexpr int kRoundTripLiquid = 0; // первая жидкость из drinknames[] + +ObjData::shared_ptr MakeJar(const std::string &name) { + auto prototype = std::make_shared(kRoundTripJarVnum); + auto jar = std::make_shared(*prototype); + jar->set_type(EObjType::kLiquidContainer); + jar->set_short_description(name); + jar->set_aliases(name); + for (int c = grammar::ECase::kFirstCase; c <= grammar::ECase::kLastCase; ++c) { + jar->set_PName(static_cast(c), name); + } + return jar; +} + +} // namespace + +TEST(LiquidNameRoundTrip, FillAndEmptyRestoresTheName) { + // "древний череп" -- название с кириллицей, на нём баг и был виден. + const std::string original = "\xD0\xB4\xD1\x80\xD0\xB5\xD0\xB2\xD0\xBD\xD0\xB8\xD0\xB9 " + "\xD1\x87\xD0\xB5\xD1\x80\xD0\xB5\xD0\xBF"; + auto jar = MakeJar(original); + + name_to_drinkcon(jar.get(), kRoundTripLiquid); + ASSERT_NE(jar->get_short_description(), original) << "жидкость должна попасть в название"; + + name_from_drinkcon(jar.get()); + EXPECT_EQ(jar->get_short_description(), original) + << "после опустошения название обязано вернуться байт в байт, без хвостового пробела"; + EXPECT_EQ(jar->get_PName(grammar::ECase::kNom), original) << "и в падежах тоже"; +} + +TEST(LiquidNameRoundTrip, RepeatedFillsDoNotAccumulateSpaces) { + // Именно так лишние пробелы и копились: остаток разделителя оставался в названии, + // и следующая заливка приклеивала к нему ещё один. + const std::string original = "\xD1\x81\xD0\xBE\xD1\x81\xD1\x83\xD0\xB4"; // "сосуд" + auto jar = MakeJar(original); + + for (int i = 0; i < 3; ++i) { + name_to_drinkcon(jar.get(), kRoundTripLiquid); + name_from_drinkcon(jar.get()); + } + + EXPECT_EQ(jar->get_short_description(), original); + EXPECT_EQ(jar->get_short_description().find(" "), std::string::npos) + << "двойных пробелов в названии быть не должно"; +} + +TEST(LiquidNameRoundTrip, StoredExtraSpaceHealsOnReload) { + // Имена, испорченные прежней обрезкой, уже лежат в файлах вещей: "огромная дубовая бочка" + // с хвостовым пробелом перед разделителем. Правка разделителя такое имя не чинила -- снимала + // ровно " с ", получала имя с хвостовым пробелом и приклеивала разделитель обратно, так что + // два пробела всплывали снова при каждой загрузке. Загрузка вещи гоняет ту же пару + // name_from_drinkcon + name_to_drinkcon, поэтому чиниться такое имя должно само. + const std::string original = "огромная дубовая бочка"; + const std::string liquid = drinknames[kRoundTripLiquid]; + auto jar = MakeJar(original + " с " + liquid); + jar->set_aliases(original + " " + liquid); + + name_from_drinkcon(jar.get()); + EXPECT_EQ(jar->get_short_description(), original) << "хвостовой пробел обязан уйти вместе с жидкостью"; + + name_to_drinkcon(jar.get(), kRoundTripLiquid); + EXPECT_EQ(jar->get_short_description(), original + " с " + liquid); + EXPECT_EQ(jar->get_PName(grammar::ECase::kNom), original + " с " + liquid) << "и в падежах тоже"; + EXPECT_EQ(jar->get_aliases().find(" "), std::string::npos) << "в синонимах двойных пробелов тоже быть не должно"; +} + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/tests/meson.build b/tests/meson.build index f410a21a35..810104b4a2 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -24,6 +24,7 @@ test_sources = files( 'blocking.queue.cpp', 'boards.changelog.cpp', 'boards.news.cpp', + 'boards.encoding.cpp', 'msdp.parser.cpp', 'msdp.builder.cpp', 'char.affects.cpp', @@ -37,12 +38,19 @@ test_sources = files( 'char.morph.copy.cpp', 'obj.copy.cpp', 'obj.liquid_core.cpp', + 'liquid.name_roundtrip.cpp', 'spell_item_convert.cpp', + 'password.encoding.cpp', 'act.makefood.cpp', 'utils.editor.cpp', 'utils.string.cpp', 'utils.encoding.cpp', 'accounts.storage.cpp', + 'translit_koi8.cpp', + 'parser_wrapper.encoding.cpp', + 'utf8.cpp', + 'native_text.cpp', + 'text_semantics.cpp', 'fight.penalties.cpp', 'bonus.command.parser.cpp', 'quested.cpp', diff --git a/tests/native_text.cpp b/tests/native_text.cpp new file mode 100644 index 0000000000..9cc6ccb011 --- /dev/null +++ b/tests/native_text.cpp @@ -0,0 +1,500 @@ +// Unit tests for the native-encoding character helpers (src/utils/native_text.*, issue #3681). +// +// Pure ASCII: non-ASCII fixtures are spelled as UTF-8 byte escapes, so the test data does not +// depend on how an editor happens to save this file. + +#include "utils/native_text.h" +#include "utils/utf8.h" +#include "utils/russian_keys.h" + +#include + +#include +#include +#include +#include + +namespace { + +// "Privet": 6 Cyrillic code points, 12 UTF-8 bytes. (Name-prefixed: the test files are +// unity-built, so a plain kPrivet would clash with the one in tests/utf8.cpp.) +const char *const kNtPrivet = "\xD0\x9F\xD1\x80\xD0\xB8\xD0\xB2\xD0\xB5\xD1\x82"; + +} // namespace + + +namespace { + + +} // namespace + +TEST(NativeText, CharCountAscii) { + EXPECT_EQ(native_text::char_count("Hello"), 5u); + EXPECT_EQ(native_text::char_count(kNtPrivet, kNtPrivet + 4), 2u); +} + +TEST(NativeText, CharCountReflectsEncoding) { + EXPECT_EQ(native_text::char_count(kNtPrivet), 6u); + EXPECT_EQ(native_text::char_count(kNtPrivet, kNtPrivet + 12), 6u); +} + +TEST(NativeText, CapitalizeAscii) { + char buf[] = "hello"; + native_text::capitalize_first(buf); + EXPECT_STREQ(buf, "Hello"); + + char empty[] = ""; + native_text::capitalize_first(empty); // must not touch the terminator + EXPECT_STREQ(empty, ""); + + char already[] = "X"; + native_text::capitalize_first(already); + EXPECT_STREQ(already, "X"); +} + +TEST(NativeText, CapitalizeCyrillic) { + char buf[] = "\xD0\xBF\xD1\x80\xD0\xB8\xD0\xB2\xD0\xB5\xD1\x82"; // "privet" + native_text::capitalize_first(buf); + EXPECT_STREQ(buf, "\xD0\x9F\xD1\x80\xD0\xB8\xD0\xB2\xD0\xB5\xD1\x82"); // "Privet" +} + +TEST(NativeText, CharBytes) { + EXPECT_EQ(native_text::char_bytes("A"), 1u); + EXPECT_EQ(native_text::char_bytes(kNtPrivet), 2u); // Cyrillic lead -> 2 bytes + EXPECT_EQ(native_text::char_bytes("\xF0\x9F\x98\x80"), 4u); // 4-byte code point + EXPECT_EQ(native_text::char_bytes("\xD0"), 1u); // truncated lead: 1 byte present +} + +TEST(NativeText, TruncateOffset) { + const std::string_view p(kNtPrivet, 12); + EXPECT_EQ(native_text::truncate_offset(p, 100), 12u); // past end -> full size + EXPECT_EQ(native_text::truncate_offset(p, 0), 0u); + // 5 bytes lands mid-character; back up to the boundary after 2 code points (4 bytes). + EXPECT_EQ(native_text::truncate_offset(p, 5), 4u); + EXPECT_EQ(native_text::truncate_offset(p, 4), 4u); +} + +TEST(NativeText, CompareCiAscii) { + EXPECT_EQ(native_text::compare_ci("abc", "abc"), 0); + EXPECT_EQ(native_text::compare_ci("ABC", "abc"), 0); // case-insensitive + EXPECT_EQ(native_text::compare_ci("AbC", "aBc"), 0); + EXPECT_LT(native_text::compare_ci("abc", "abd"), 0); // ordering by first mismatch + EXPECT_GT(native_text::compare_ci("abd", "abc"), 0); + EXPECT_LT(native_text::compare_ci("ab", "abc"), 0); // prefix orders first + EXPECT_GT(native_text::compare_ci("abc", "ab"), 0); + EXPECT_EQ(native_text::compare_ci("", ""), 0); + EXPECT_LT(native_text::compare_ci("", "a"), 0); +} + +TEST(NativeText, CompareCiCyrillicIsCaseInsensitive) { + // "PRIVET" vs "privet" in Cyrillic: must compare equal in BOTH encodings -- under KOI8-R via + // the byte table, under UTF-8 via the code-point fold. This is the property that a naive + // "just compare bytes" UTF-8 migration would silently lose. + const char *const upper = "\xD0\x9F\xD0\xA0\xD0\x98\xD0\x92\xD0\x95\xD0\xA2"; + const char *const lower = "\xD0\xBF\xD1\x80\xD0\xB8\xD0\xB2\xD0\xB5\xD1\x82"; + EXPECT_EQ(native_text::compare_ci(upper, lower), 0); + EXPECT_EQ(native_text::compare_ci(upper, upper), 0); + EXPECT_NE(native_text::compare_ci(upper, "\xD0\xBF\xD1\x80\xD0\xB8"), 0); // prefix differs +} + +TEST(NativeText, IsAlnumChar) { + EXPECT_TRUE(native_text::is_alnum_char("a")); + EXPECT_TRUE(native_text::is_alnum_char("Z")); + EXPECT_TRUE(native_text::is_alnum_char("7")); + EXPECT_FALSE(native_text::is_alnum_char(" ")); + EXPECT_FALSE(native_text::is_alnum_char("!")); + EXPECT_FALSE(native_text::is_alnum_char(".")); + EXPECT_FALSE(native_text::is_alnum_char("")); // terminator is not alphanumeric + // A Cyrillic letter is ONE alphanumeric character; its trail byte must not be read as + // punctuation (which is what the raw byte table would do and what breaks tokenisation). + EXPECT_TRUE(native_text::is_alnum_char(kNtPrivet)); + EXPECT_TRUE(native_text::is_alnum_char("\xD0\x81")); // Yo + EXPECT_TRUE(native_text::is_alnum_char("\xD1\x91")); // yo +} + +TEST(NativeText, IsAlphaChar) { + EXPECT_TRUE(native_text::is_alpha_char("a")); + EXPECT_TRUE(native_text::is_alpha_char("Z")); + EXPECT_FALSE(native_text::is_alpha_char("7")); // digit: alnum but not alpha + EXPECT_FALSE(native_text::is_alpha_char(" ")); + EXPECT_FALSE(native_text::is_alpha_char("")); + EXPECT_TRUE(native_text::is_alpha_char(kNtPrivet)); + EXPECT_TRUE(native_text::is_alpha_char("\xD0\x81")); // Yo +} + +TEST(NativeText, IsUpperChar) { + EXPECT_TRUE(native_text::is_upper_char("A")); + EXPECT_FALSE(native_text::is_upper_char("a")); + EXPECT_FALSE(native_text::is_upper_char("7")); + EXPECT_FALSE(native_text::is_upper_char("")); + // The byte table cannot see these: a UTF-8 Cyrillic lead byte is outside its uppercase + // range, which is why the anti-caps filter stopped working for Russian. + EXPECT_TRUE(native_text::is_upper_char("\xD0\x9F")); // P + EXPECT_FALSE(native_text::is_upper_char("\xD0\xBF")); // p + EXPECT_TRUE(native_text::is_upper_char("\xD0\x81")); // Yo + EXPECT_FALSE(native_text::is_upper_char("\xD1\x91")); // yo +} + +TEST(NativeText, CharsEqualCi) { + EXPECT_TRUE(native_text::chars_equal_ci("a", "a")); + EXPECT_TRUE(native_text::chars_equal_ci("a", "A")); + EXPECT_TRUE(native_text::chars_equal_ci("Z", "z")); + EXPECT_FALSE(native_text::chars_equal_ci("a", "b")); + EXPECT_FALSE(native_text::chars_equal_ci("a", "")); + // The regression this whole step exists for: with the raw KOI8-R byte table the lead + // bytes of "P"/"p" fold equal but the trail bytes differ, so the match was lost. + EXPECT_TRUE(native_text::chars_equal_ci("\xD0\x9F", "\xD0\xBF")); // P vs p + EXPECT_TRUE(native_text::chars_equal_ci("\xD0\x81", "\xD1\x91")); // Yo vs yo + EXPECT_FALSE(native_text::chars_equal_ci("\xD0\x9F", "\xD1\x80")); // P vs r +} + +TEST(NativeText, CopyLowerChar) { + char buf[8] = {0}; + EXPECT_EQ(native_text::copy_lower_char("A", buf), 1u); + EXPECT_STREQ(buf, "a"); + EXPECT_EQ(native_text::copy_lower_char("z", buf), 1u); + EXPECT_STREQ(buf, "z"); + EXPECT_EQ(native_text::copy_lower_char("7", buf), 1u); + EXPECT_STREQ(buf, "7"); + std::memset(buf, 0, sizeof(buf)); + EXPECT_EQ(native_text::copy_lower_char("\xD0\x9F", buf), 2u); // P -> p + EXPECT_STREQ(buf, "\xD0\xBF"); + std::memset(buf, 0, sizeof(buf)); + EXPECT_EQ(native_text::copy_lower_char("\xD0\x81", buf), 2u); // Yo -> yo + EXPECT_STREQ(buf, "\xD1\x91"); + // In-place folding must be safe (the lowercase form keeps the byte length). + char inplace[] = "\xD0\x9F"; + EXPECT_EQ(native_text::copy_lower_char(inplace, inplace), 2u); + EXPECT_STREQ(inplace, "\xD0\xBF"); +} + +TEST(NativeText, LastCharOffset) { + EXPECT_EQ(native_text::last_char_offset(""), 0u); + EXPECT_EQ(native_text::last_char_offset("a"), 0u); + EXPECT_EQ(native_text::last_char_offset("abc"), 2u); + EXPECT_EQ(native_text::last_char_offset(kNtPrivet), 10u); // 6 chars, last starts at byte 10 + const std::string_view s(kNtPrivet, 12); + EXPECT_EQ(s.substr(native_text::last_char_offset(s)), "\xD1\x82"); // final char "t" + EXPECT_EQ(native_text::last_char_offset("\xF0\x9F\x98\x80"), 0u); // single 4-byte char +} + +TEST(NativeText, ListContainsChar) { + EXPECT_TRUE(native_text::list_contains_char("abc", "b")); + EXPECT_FALSE(native_text::list_contains_char("abc", "d")); + EXPECT_FALSE(native_text::list_contains_char("abc", "")); + EXPECT_FALSE(native_text::list_contains_char("", "a")); + EXPECT_FALSE(native_text::list_contains_char("abc", "A")); // case-sensitive, like strchr + // A multibyte character must match as a whole and never on a partial byte sequence. + const char *const list = "\xD1\x88\xD1\x89\xD0\xB6\xD1\x87"; // sh shch zh ch + EXPECT_TRUE(native_text::list_contains_char(list, "\xD1\x89")); + EXPECT_TRUE(native_text::list_contains_char(list, "\xD0\xB6")); + EXPECT_FALSE(native_text::list_contains_char(list, "\xD1\x82")); + EXPECT_FALSE(native_text::list_contains_char(list, "\xD1")); // lead byte alone +} + +TEST(NativeText, CharRangeIteratesWholeCharacters) { + std::vector got; + for (auto c : native_text::chars(kNtPrivet)) { + got.emplace_back(c); + } + ASSERT_EQ(got.size(), 6u); // 6 letters, not 12 bytes + EXPECT_EQ(got.front(), "\xD0\x9F"); // whole "P", both bytes + EXPECT_EQ(got.back(), "\xD1\x82"); // whole "t" + + got.clear(); + for (auto c : native_text::chars("abc")) { + got.emplace_back(c); + } + EXPECT_EQ(got, (std::vector{"a", "b", "c"})); + + got.clear(); + for (auto c : native_text::chars("")) { + got.emplace_back(c); + } + EXPECT_TRUE(got.empty()); +} + +TEST(NativeText, WholeStringCaseTransforms) { + std::string s = "Hello World"; + native_text::to_lower(s); + EXPECT_EQ(s, "hello world"); + native_text::to_upper(s); + EXPECT_EQ(s, "HELLO WORLD"); + + char buf[] = "MiXeD"; + native_text::to_lower(buf); + EXPECT_STREQ(buf, "mixed"); + + std::string ru = "\xD0\x9F\xD0\xA0\xD0\x98"; // "PRI" in Cyrillic + native_text::to_lower(ru); + EXPECT_EQ(ru, "\xD0\xBF\xD1\x80\xD0\xB8"); // "pri" + native_text::to_upper(ru); + EXPECT_EQ(ru, "\xD0\x9F\xD0\xA0\xD0\x98"); +} + +TEST(NativeText, RussianKeysMatchFirstCharCode) { + // The switch-dispatch contract: for every Russian letter, the constant in russian_keys.h must + // equal what first_char_code() returns for that letter in the build's native encoding. If this + // ever drifts, menus and OLC editors silently stop responding to that key. + struct Case { const char *koi8; const char *utf8; char32_t expected; }; + const Case cases[] = { + {"\xC1", "\xD0\xB0", rus::kA}, {"\xE1", "\xD0\x90", rus::kAUpper}, + {"\xC4", "\xD0\xB4", rus::kDe}, {"\xE4", "\xD0\x94", rus::kDeUpper}, + {"\xCE", "\xD0\xBD", rus::kEn}, {"\xEE", "\xD0\x9D", rus::kEnUpper}, + {"\xD1", "\xD1\x8F", rus::kYa}, {"\xF1", "\xD0\xAF", rus::kYaUpper}, + {"\xA3", "\xD1\x91", rus::kYo}, {"\xB3", "\xD0\x81", rus::kYoUpper}, + {"\xD7", "\xD0\xB2", rus::kVe}, {"\xC8", "\xD1\x85", rus::kHa}, + }; + for (const auto &c : cases) { + const char *const input = c.utf8; + EXPECT_EQ(native_text::first_char_code(input), c.expected); + } + + // ASCII keys are unchanged by the flip and stay ordinary character literals in the switches. + EXPECT_EQ(native_text::first_char_code("y"), static_cast('y')); + EXPECT_EQ(native_text::first_char_code("N"), static_cast('N')); + EXPECT_EQ(native_text::first_char_code(""), 0u); + EXPECT_EQ(native_text::first_char_code(nullptr), 0u); +} + +TEST(NativeText, TransliterationIsStableAcrossTheFlip) { + // A player's save file is named after the transliterated character name, so this mapping is + // on-disk state: if it ever changes, every existing character stops being found. Each row is + // the letter in both encodings and the single ASCII character it must always produce -- the + // values were taken from what the byte-wise implementation produced before the migration. + struct Row { const char *koi8; const char *utf8; char expected; }; + static const Row kRows[] = { + {"\xC1", "\xD0\xB0", 'a'}, + {"\xE1", "\xD0\x90", 'a'}, + {"\xC2", "\xD0\xB1", 'b'}, + {"\xE2", "\xD0\x91", 'b'}, + {"\xD7", "\xD0\xB2", 'v'}, + {"\xF7", "\xD0\x92", 'v'}, + {"\xC7", "\xD0\xB3", 'g'}, + {"\xE7", "\xD0\x93", 'g'}, + {"\xC4", "\xD0\xB4", 'd'}, + {"\xE4", "\xD0\x94", 'd'}, + {"\xC5", "\xD0\xB5", 'e'}, + {"\xE5", "\xD0\x95", 'e'}, + {"\xA3", "\xD1\x91", '9'}, + {"\xB3", "\xD0\x81", '9'}, + {"\xD6", "\xD0\xB6", '1'}, + {"\xF6", "\xD0\x96", '1'}, + {"\xDA", "\xD0\xB7", 'z'}, + {"\xFA", "\xD0\x97", 'z'}, + {"\xC9", "\xD0\xB8", 'i'}, + {"\xE9", "\xD0\x98", 'i'}, + {"\xCA", "\xD0\xB9", 'j'}, + {"\xEA", "\xD0\x99", 'j'}, + {"\xCB", "\xD0\xBA", 'k'}, + {"\xEB", "\xD0\x9A", 'k'}, + {"\xCC", "\xD0\xBB", 'l'}, + {"\xEC", "\xD0\x9B", 'l'}, + {"\xCD", "\xD0\xBC", 'm'}, + {"\xED", "\xD0\x9C", 'm'}, + {"\xCE", "\xD0\xBD", 'n'}, + {"\xEE", "\xD0\x9D", 'n'}, + {"\xCF", "\xD0\xBE", 'o'}, + {"\xEF", "\xD0\x9E", 'o'}, + {"\xD0", "\xD0\xBF", 'p'}, + {"\xF0", "\xD0\x9F", 'p'}, + {"\xD2", "\xD1\x80", 'r'}, + {"\xF2", "\xD0\xA0", 'r'}, + {"\xD3", "\xD1\x81", 's'}, + {"\xF3", "\xD0\xA1", 's'}, + {"\xD4", "\xD1\x82", 't'}, + {"\xF4", "\xD0\xA2", 't'}, + {"\xD5", "\xD1\x83", 'y'}, + {"\xF5", "\xD0\xA3", 'y'}, + {"\xC6", "\xD1\x84", 'f'}, + {"\xE6", "\xD0\xA4", 'f'}, + {"\xC8", "\xD1\x85", 'h'}, + {"\xE8", "\xD0\xA5", 'h'}, + {"\xC3", "\xD1\x86", 'c'}, + {"\xE3", "\xD0\xA6", 'c'}, + {"\xDE", "\xD1\x87", '7'}, + {"\xFE", "\xD0\xA7", '7'}, + {"\xDB", "\xD1\x88", '4'}, + {"\xFB", "\xD0\xA8", '4'}, + {"\xDD", "\xD1\x89", '6'}, + {"\xFD", "\xD0\xA9", '6'}, + {"\xDF", "\xD1\x8A", '8'}, + {"\xFF", "\xD0\xAA", '8'}, + {"\xD9", "\xD1\x8B", '3'}, + {"\xF9", "\xD0\xAB", '3'}, + {"\xD8", "\xD1\x8C", '2'}, + {"\xF8", "\xD0\xAC", '2'}, + {"\xDC", "\xD1\x8D", '5'}, + {"\xFC", "\xD0\xAD", '5'}, + {"\xC0", "\xD1\x8E", '0'}, + {"\xE0", "\xD0\xAE", '0'}, + {"\xD1", "\xD1\x8F", 'q'}, + {"\xF1", "\xD0\xAF", 'q'}, + }; + for (const auto &r : kRows) { + const char *const input = r.utf8; + EXPECT_EQ(native_text::translit_to_filename(input), std::string(1, r.expected)) + << "transliteration drifted for " << r.utf8; + } + + // ASCII is lowercased and digits pass through, as before. + EXPECT_EQ(native_text::translit_to_filename("Vasya"), "vasya"); + EXPECT_EQ(native_text::translit_to_filename("Abc123"), "abc123"); + EXPECT_EQ(native_text::translit_to_filename(""), ""); + + // A whole name: "Vasya" in Cyrillic must give the same file name in both encodings. + const char *const name = "\xD0\x92\xD0\xB0\xD1\x81\xD1\x8F"; // "Vasya" + EXPECT_EQ(native_text::translit_to_filename(name), "vasq"); +} + +TEST(NativeText, FromKoi8BringsDiskTextIntoTheNativeEncoding) { + // Data files (world, configs, help, boards, saves) are stored in KOI8-R. from_koi8() is the + // boundary that brings them into whatever the engine runs on: a no-op today, a transcode + // after the flip. Both directions are asserted here so the wiring can be trusted before the + // sources that need it are converted. + const char *const koi8_privet = "\xF0\xD2\xC9\xD7\xC5\xD4"; // "Privet", KOI8-R + const char *const utf8_privet = "\xD0\x9F\xD1\x80\xD0\xB8\xD0\xB2\xD0\xB5\xD1\x82"; // the same, UTF-8 + + EXPECT_EQ(native_text::from_koi8(""), ""); + EXPECT_EQ(native_text::from_koi8("plain ascii 123"), "plain ascii 123"); // ASCII never changes + + EXPECT_EQ(native_text::from_koi8(koi8_privet), utf8_privet); + // The result must be well-formed UTF-8 -- libfort aborts the process on anything else. + EXPECT_TRUE(utf8::is_valid(native_text::from_koi8(koi8_privet))); + EXPECT_EQ(native_text::char_count(native_text::from_koi8(koi8_privet)), 6u); +} + +TEST(NativeText, ToKoi8IsTheInverseBoundary) { + // The counterpart of from_koi8: used where something downstream speaks KOI8-R -- the legacy + // client code pages (their tables are indexed by KOI8-R bytes) and the on-disk formats. + const char *const koi8_privet = "\xF0\xD2\xC9\xD7\xC5\xD4"; + const char *const utf8_privet = "\xD0\x9F\xD1\x80\xD0\xB8\xD0\xB2\xD0\xB5\xD1\x82"; + + EXPECT_EQ(native_text::to_koi8(""), ""); + EXPECT_EQ(native_text::to_koi8("plain ascii 123"), "plain ascii 123"); + + EXPECT_EQ(native_text::to_koi8(utf8_privet), koi8_privet); + // Round trip through the boundary must return the original text. + EXPECT_EQ(native_text::from_koi8(native_text::to_koi8(utf8_privet)), utf8_privet); +} + +// KOI8-R spellings, so the fixture is built through from_koi8 and comes out native in either +// build. Words chosen to expose the KOI8-R byte order: there the Russian letters are NOT in +// alphabetical order, so a plain byte comparison would sort these wrong. +TEST(NativeText, SortKeyOrdersRussianAlphabetically) { + const auto native = [](const char *koi8) { return native_text::from_koi8(koi8); }; + // "arbuz", "banan", "vishnya", "yabloko" -- a, b, v, ya. + const std::string a = native("\xC1\xD2\xC2\xD5\xDA"); + const std::string b = native("\xC2\xC1\xCE\xC1\xCE"); + const std::string v = native("\xD7\xC9\xDB\xCE\xD1"); + const std::string ya = native("\xD1\xC2\xCC\xCF\xCB\xCF"); + + EXPECT_LT(native_text::sort_key(a), native_text::sort_key(b)); + EXPECT_LT(native_text::sort_key(b), native_text::sort_key(v)); + EXPECT_LT(native_text::sort_key(v), native_text::sort_key(ya)); + // "zhaba" and "ivan": alphabetically zh comes before i, but in KOI8-R its byte (0xD6) is + // above i's (0xC9) -- which is the whole reason the key exists. + const std::string zh = native("\xD6\xC1\xC2\xC1"); + const std::string i = native("\xC9\xD7\xC1\xCE"); + EXPECT_LT(native_text::sort_key(zh), native_text::sort_key(i)); + // ASCII keeps its own order and stays below the Cyrillic. + EXPECT_LT(native_text::sort_key("abc"), native_text::sort_key("abd")); + EXPECT_LT(native_text::sort_key("zzz"), native_text::sort_key(a)); +} + +TEST(NativeText, SortKeyIsStableAcrossEncodings) { + // The key is a byte string in a fixed encoding, so the very same words must produce the very + // same key no matter which encoding the engine runs in. Pinned literally. + EXPECT_EQ(native_text::sort_key(native_text::from_koi8("\xC1\xD2\xC2\xD5\xDA")), + "\xE0\xF0\xE1\xF3\xE7"); // "arbuz" in Windows-1251 + EXPECT_EQ(native_text::sort_key("plain ascii"), "plain ascii"); +} + +TEST(NativeText, FromDiskTextIsIdempotent) { + // The bug this guards against: a config the engine both reads and writes was transcoded + // unconditionally on load, so text already saved in the native encoding got transcoded a + // second time -- and since the save wrote that back, every Cyrillic byte doubled on each + // load/save cycle. cfg/mechanics/obj_sets.xml grew from 120 KB to 6.7 GB that way. + const std::string koi8 = "\xD0\xD2\xC9\xD7\xC5\xD4"; // "privet" in KOI8-R + const std::string once = native_text::from_disk_text(koi8); + const std::string twice = native_text::from_disk_text(once); + EXPECT_EQ(once, twice) << "reading back what we wrote must not change it"; + // However many times it goes through, the size must not grow. + std::string acc = koi8; + for (int i = 0; i < 10; ++i) { + acc = native_text::from_disk_text(acc); + } + EXPECT_EQ(acc, once); + + EXPECT_EQ(once, "\xD0\xBF\xD1\x80\xD0\xB8\xD0\xB2\xD0\xB5\xD1\x82"); + // ASCII is spelled the same in both encodings and must pass through untouched. + EXPECT_EQ(native_text::from_disk_text(""), ""); + EXPECT_EQ(native_text::from_disk_text(""), ""); +} + +TEST(NativeText, PadRightCountsCharactersNotBytes) { + // The replacement for printf's "%-Ns" wherever the value can be Russian: printf counts the + // field in bytes, so a Cyrillic word ate twice its share and the column drifted (issue #3681). + const std::string privet = native_text::from_koi8("\xD0\xD2\xC9\xD7\xC5\xD4"); // 6 letters + const std::string padded = native_text::pad_right(privet, 10); + EXPECT_EQ(native_text::char_count(padded), 10u) << "padding must be measured in characters"; + EXPECT_EQ(padded.substr(0, privet.size()), privet); + EXPECT_EQ(padded.substr(privet.size()), " "); + + // ASCII behaves exactly like "%-Ns" did. + EXPECT_EQ(native_text::pad_right("ab", 5), "ab "); + EXPECT_EQ(native_text::pad_right("", 3), " "); + // Longer than the field: returned untouched, again like printf. + EXPECT_EQ(native_text::pad_right("abcdef", 3), "abcdef"); + EXPECT_EQ(native_text::pad_right(privet, 2), privet); +} + +TEST(NativeText, CharOffsetCutsOnCharacterBoundaries) { + // Cutting text to fit a column by BYTES both halves the visible width under UTF-8 and can + // split a character in two, sending a broken byte to the client (issue #3681). + const std::string privet = native_text::from_koi8("\xD0\xD2\xC9\xD7\xC5\xD4"); // 6 letters + const std::string cut = privet.substr(0, native_text::char_offset(privet, 3)); + EXPECT_EQ(native_text::char_count(cut), 3u); + EXPECT_EQ(cut, privet.substr(0, 6)); + // Asking for more than there is yields the whole string, never past the end. + EXPECT_EQ(native_text::char_offset(privet, 100), privet.size()); + EXPECT_EQ(native_text::char_offset("", 5), 0u); + EXPECT_EQ(native_text::char_offset("abcdef", 4), 4u); +} + +TEST(NativeText, ToDiskNeverTransliteratesDiskBytes) { + // Пара к границе чтения. Если строка не проходила from_disk, она держит кои-восьмые байты, + // и старый to_disk разбирал их как Latin-1 и гнал через словарь транслита: 'верий.свет' + // становился 'AIEUAxAOA.OxAO' -- необратимо. Так были съедены метки вещей, сундуки дружин + // и списки имён (issue #3681). Теперь такие байты уходят на диск как есть. + const std::string koi8_bytes = "\xD7\xC5\xD2\xC9\xCA.\xD3\xD7\xC5\xD4"; // 'верий.свет' в KOI8-R + EXPECT_EQ(native_text::to_disk(koi8_bytes), koi8_bytes) << "дисковые байты не должны меняться"; + + // А нативный текст по-прежнему переводится в кодировку диска. + const std::string native = native_text::from_koi8(koi8_bytes); + EXPECT_NE(native, koi8_bytes) << "проверка построена на том, что кодировки различаются"; + EXPECT_EQ(native_text::to_disk(native), koi8_bytes); + + // Чистая латиница одинакова в обеих кодировках и не трогается ни в одном из случаев. + EXPECT_EQ(native_text::to_disk("plain ascii"), "plain ascii"); +} + +TEST(NativeText, DiskRoundTripIsByteIdentical) { + // Правило пары: что прочитано через границу, должно уйти обратно теми же байтами. + // Нарушение этой пары -- одностороннее чтение или одностороння запись -- и съело + // метки вещей, сундуки дружин и списки имён (issue #3681). + const std::string on_disk = "\xD7\xC5\xD2\xC9\xCA.\xD3\xD7\xC5\xD4"; // 'верий.свет' в KOI8-R + + const std::string native = native_text::from_disk_text(on_disk); + EXPECT_EQ(native_text::to_disk(native), on_disk) << "чтение и запись обязаны быть зеркальны"; + + // Повторное чтение уже нативного текста ничего не меняет: именно на этом держится + // идемпотентность цикла загрузка-сохранение. + EXPECT_EQ(native_text::from_disk_text(native), native); + + // И то же самое для чистой латиницы -- она одинакова в обеих кодировках. + const std::string ascii = "plain ascii line"; + EXPECT_EQ(native_text::to_disk(native_text::from_disk_text(ascii)), ascii); +} + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/tests/parser_wrapper.encoding.cpp b/tests/parser_wrapper.encoding.cpp new file mode 100644 index 0000000000..12a7c30336 --- /dev/null +++ b/tests/parser_wrapper.encoding.cpp @@ -0,0 +1,105 @@ +// The load/save cycle of an XML config must not change what it holds (issue #3681). +// +// This pins a bug that cost a world: the document was brought into the native encoding once in +// DataNode, and then AGAIN per field in parse::AttrStr. Under KOI8-R both conversions are the +// identity, so nothing showed. Under UTF-8 every field was converted twice -- and because the +// engine writes configs back (ObjSetsLoader::Load normalises the file through save()), the damage +// accumulated: cfg/mechanics/obj_sets.xml doubled on every boot until, at 6.7 GB, reading it into +// memory killed the process. +// +// Pure ASCII: the Cyrillic fixture is spelled as UTF-8 byte escapes and brought into the native +// encoding through native_text, so the same test is correct under either build. + +#include "utils/parser_wrapper.h" +#include "utils/utils_parse.h" +#include "utils/native_text.h" +#include "utils/translit_koi8.h" + +#include + +#include +#include +#include +#include +#include + +namespace { + +// "privet, mir" in UTF-8, plus an ampersand: colour codes in real configs are written "&W", and +// the ampersand is the character XML escaping is most likely to mangle on a re-save. +const char *const kCyrillicUtf8 = + "&W" "\xD0\x9F\xD1\x80\xD0\xB8\xD0\xB2\xD0\xB5\xD1\x82" ", " "\xD0\xBC\xD0\xB8\xD1\x80" "&n"; + +std::string ReadWhole(const std::filesystem::path &path) { + std::ifstream in(path, std::ios::binary); + std::ostringstream buffer; + buffer << in.rdbuf(); + return buffer.str(); +} + +// A scratch file removed on destruction, so a failing assertion cannot leave litter behind. +class TempFile { + public: + explicit TempFile(const char *name) : path_(std::filesystem::temp_directory_path() / name) {} + ~TempFile() { std::error_code ec; std::filesystem::remove(path_, ec); } + const std::filesystem::path &path() const { return path_; } + + private: + std::filesystem::path path_; +}; + +} // namespace + +TEST(ParserWrapperEncoding, SaveLoadSaveIsByteIdentical) { + const std::string text = native_text::from_koi8(codepages::Utf8ToKoi8(kCyrillicUtf8)); + TempFile file("bylins_cfg_roundtrip.xml"); + + { + auto doc = parser_wrapper::DataNode::NewDocument(); + auto root = doc.AddChild("obj_sets"); + auto set = root.AddChild("set"); + set.SetValue("name", text); + ASSERT_TRUE(doc.Save(file.path())); + } + const std::string first = ReadWhole(file.path()); + ASSERT_FALSE(first.empty()); + + // Read it back the way the loaders do and write it out again from what we read. + { + parser_wrapper::DataNode loaded(file.path()); + ASSERT_TRUE(loaded.IsNotEmpty()); + ASSERT_TRUE(loaded.GoToChild("set")); + // The value must come back exactly as it went in -- this is where the double conversion + // showed up first. + EXPECT_EQ(parse::AttrStr(loaded, "name"), text); + + auto doc = parser_wrapper::DataNode::NewDocument(); + auto root = doc.AddChild("obj_sets"); + auto set = root.AddChild("set"); + set.SetValue("name", parse::AttrStr(loaded, "name")); + ASSERT_TRUE(doc.Save(file.path())); + } + const std::string second = ReadWhole(file.path()); + + EXPECT_EQ(first.size(), second.size()) << "the file grew on a load/save cycle"; + EXPECT_EQ(first, second) << "a load/save cycle must reproduce the file byte for byte"; +} + +TEST(ParserWrapperEncoding, ReadsAConfigStoredInKoi8) { + // Configs on disk are still KOI8-R, so reading one must transcode; and the result, once + // written back, must then stay put (covered by the test above). + TempFile file("bylins_cfg_koi8.xml"); + { + std::ofstream out(file.path(), std::ios::binary); + out << "\n\n"; + } + + parser_wrapper::DataNode loaded(file.path()); + ASSERT_TRUE(loaded.IsNotEmpty()); + ASSERT_TRUE(loaded.GoToChild("set")); + EXPECT_EQ(parse::AttrStr(loaded, "name"), + native_text::from_koi8(codepages::Utf8ToKoi8(kCyrillicUtf8))); +} + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/tests/password.encoding.cpp b/tests/password.encoding.cpp new file mode 100644 index 0000000000..0b321c7213 --- /dev/null +++ b/tests/password.encoding.cpp @@ -0,0 +1,66 @@ +// Хэш пароля считается по ДИСКОВЫМ байтам (issue #3681). +// +// Хэш -- сохранённые данные, и посчитан он когда-то по кодировке диска. Когда движок +// перешёл на UTF-8, те же буквы стали давать другие байты, и у всех, чей пароль +// с кириллицей, вход отвалился: 'Bad PW' при верном пароле. Латинские работали, потому +// что в ASCII обе кодировки совпадают. +// +// Проверять хэш напрямую нельзя -- соль случайная, -- поэтому свойство проверяется через +// наблюдаемое следствие: сравнение смотрит на дисковую форму пароля, а не на байты +// в памяти. + +#include "administration/password.h" +#include "utils/native_text.h" + +#include + +#include + +namespace { + +// "пароль" в UTF-8 байтами, чтобы тест не зависел от того, как редактор сохранил файл. +const std::string kCyrillic = "\xD0\xBF\xD0\xB0\xD1\x80\xD0\xBE\xD0\xBB\xD1\x8C"; + +} // namespace + +TEST(PasswordEncoding, CyrillicPasswordMatchesItself) { + const std::string hash = Password::generate_md5_hash(kCyrillic); + EXPECT_TRUE(Password::compare_password(hash, kCyrillic)); +} + +TEST(PasswordEncoding, WrongCyrillicPasswordIsRejected) { + const std::string hash = Password::generate_md5_hash(kCyrillic); + const std::string other = kCyrillic + "\xD1\x8B"; // тот же пароль плюс "ы" + EXPECT_FALSE(Password::compare_password(hash, other)); +} + +TEST(PasswordEncoding, HashIsTakenOverTheOnDiskForm) { + // Ключевое свойство. Два разных набора байт в памяти, у которых ОДНА дисковая форма: + // длинное тире на диске становится обычным дефисом (в KOI8-R его попросту нет, см. + // словарь замен). Если хэш считать по байтам памяти, эти пароли разойдутся; если по + // дисковой форме -- совпадут. Именно на этом расхождении и отвалился вход. + const std::string with_em_dash = "pa\xE2\x80\x94rol"; // pa—rol + const std::string with_hyphen = "pa-rol"; + + ASSERT_NE(with_em_dash, with_hyphen) << "проверка построена на том, что в памяти они разные"; + ASSERT_EQ(native_text::to_disk(with_em_dash), native_text::to_disk(with_hyphen)) + << "...а на диске -- одинаковые"; + + const std::string hash = Password::generate_md5_hash(with_em_dash); + EXPECT_TRUE(Password::compare_password(hash, with_hyphen)) + << "хэш обязан считаться по дисковой форме, иначе старые хэши не сойдутся"; +} + +TEST(PasswordEncoding, AsciiIsUnaffected) { + // Латиница в обеих кодировках одинакова -- на ней баг и не проявлялся. + // + // Неверный пароль отличается ПЕРВОЙ буквой, а не последней, нарочно: на macOS crypt() -- + // классический DES, он смотрит только первые восемь символов. Пара, различающаяся + // десятым символом, там даёт один и тот же хэш, и проверка «неверный пароль отвергнут» + // молча превращается в свою противоположность. + const std::string hash = Password::generate_md5_hash("parolparol"); + EXPECT_TRUE(Password::compare_password(hash, "parolparol")); + EXPECT_FALSE(Password::compare_password(hash, "xarolparol")); +} + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/tests/spell_item_convert.cpp b/tests/spell_item_convert.cpp index 6f08a5f8c2..d6ca1c63a6 100644 --- a/tests/spell_item_convert.cpp +++ b/tests/spell_item_convert.cpp @@ -203,4 +203,53 @@ TEST(PotencyFromProto, LegacyStoredPotencyIsNotProto) { EXPECT_FALSE(IsPotencyFromProto(p.get())); } +// ------------------------------------------------------------- issue #3749: пометка зон +// +// Возврат конвертера -- это ответ на вопрос «надо ли сохранять зону». Раньше он был +// безусловной единицей, и предмет, которому мигрировать нечего, помечал свою зону на +// каждом буте: ключей у него не появляется, guard «уже мигрировано» снова ложен, зона +// снова в очереди. На боевом так вечно переписывались зоны 1, 40, 498, 735 и 830. + +TEST(MigrationMarksZone, ScrollWithNothingToMigrateDoesNotMarkZone) { + auto p = make_proto(EObjType::kScroll, 0, 0, 0, 0); + EXPECT_EQ(0, ConvertSpellItemToEValueKey(p.get(), true)) << "сохранять нечего -- метить зону незачем"; + EXPECT_LT(key(p, ObjVal::EValueKey::kSpell1Num), 0) << "ключей и правда не появилось"; + // И на следующем буте ответ тот же, иначе зона метилась бы вечно. + EXPECT_EQ(0, ConvertSpellItemToEValueKey(p.get(), true)); +} + +TEST(MigrationMarksZone, PotionWithNothingToMigrateDoesNotMarkZone) { + auto p = make_proto(EObjType::kPotion, 0, 0, 0, 0); + EXPECT_EQ(0, ConvertPotionToEValueKey(p.get(), true)); + EXPECT_EQ(0, ConvertPotionToEValueKey(p.get(), true)); +} + +TEST(MigrationMarksZone, RealMigrationStillMarksZoneOnce) { + auto p = make_proto(EObjType::kPotion, 30, 28, -1, -1); + EXPECT_EQ(1, ConvertPotionToEValueKey(p.get(), true)) << "ключ появился -- зону сохранить надо"; + EXPECT_EQ(28, key(p, ObjVal::EValueKey::kSpell1Num)); + EXPECT_EQ(0, ConvertPotionToEValueKey(p.get(), true)) << "но ровно один раз"; +} + +TEST(MigrationMarksZone, LiquidCoreSeedingNeverMarksZone) { + // Ядро сосуда уходит на диск как обычные values (get_val/set_val перенаправлены в эти же + // ключи), а засев по замыслу повторяется на каждой загрузке -- значит помечать зону нельзя + // никогда, иначе она переписывается вечно. + // + // Воспроизводим именно загрузку мимо set_val: значения кладём, пока тип ещё не жидкостный, + // так они попадают в сырой val[], а ключей не появляется. Если задать их сразу сосуду, + // set_val запишет ключи сам, guard сработает, и до засева дело не дойдёт -- тест окажется + // пустым. + auto p = std::make_shared(100601); + p->set_type(EObjType::kOther); + p->set_val(0, 6); + p->set_val(1, 6); + p->set_val(2, 0); + p->set_type(EObjType::kLiquidContainer); + ASSERT_LT(key(p, ObjVal::EValueKey::kLiquidCapacity), 0) << "ключей до засева быть не должно"; + + EXPECT_EQ(0, ConvertDrinkconLiquidCore(p.get(), true)) << "засев есть, а метить зону нельзя"; + EXPECT_EQ(6, key(p, ObjVal::EValueKey::kLiquidCapacity)) << "но ключи при этом засеяны"; +} + // vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/tests/text_semantics.cpp b/tests/text_semantics.cpp new file mode 100644 index 0000000000..46fc257a60 --- /dev/null +++ b/tests/text_semantics.cpp @@ -0,0 +1,204 @@ +// Regression tests for the byte-vs-character migration (issue #3681). +// +// These pin the *observable behaviour* of the text routines that used to assume 1 byte == 1 +// character: name matching, case-insensitive comparison, argument splitting and Russian name +// declension. Several of these paths had no coverage at all before the migration touched them. +// +// The Russian literals below are deliberately written as literals rather than byte escapes: the +// file is compiled in whatever encoding the engine is built with (KOI8-R today, UTF-8 after the +// flip), and the routines under test operate in that same native encoding. The expectations are +// therefore valid in both, and this file doubles as the guard that the flip did not change +// user-visible behaviour. + +#include "utils/utils_string.h" +#include "utils/utils_parse.h" +#include "utils/mud_string.h" +#include "gameplay/core/genchar.h" +#include "utils/grammar/gender.h" +#include "engine/structs/structs.h" + +#include + +#include + +namespace { + +std::string declension(const char *name, EGender sex, int case_num) { + char buf[128] = {0}; + GetCase(name, sex, case_num, buf); + return std::string(buf); +} + +std::string first_argument(const char *line) { + char buf[kMaxInputLength] = {0}; + one_argument(line, buf); + return std::string(buf); +} + +} // namespace + +// ---------------------------------------------------------------------------- isname + +TEST(TextSemantics, IsnameMatchesAsciiKeywords) { + EXPECT_TRUE(isname("sword", "a long sword")); + EXPECT_TRUE(isname("long", "a long sword")); + EXPECT_TRUE(isname("SWORD", "a long sword")); // case-insensitive + EXPECT_TRUE(isname("swo", "a long sword")); // prefix + EXPECT_FALSE(isname("xyzzy", "a long sword")); +} + +TEST(TextSemantics, IsnameMatchesRussianKeywords) { + EXPECT_TRUE(isname("меч", "меч длинный")); + EXPECT_TRUE(isname("длинный", "меч длинный")); + EXPECT_TRUE(isname("ме", "меч длинный")); // prefix +} + +TEST(TextSemantics, IsnameIsCaseInsensitiveForRussian) { + // Regression: with byte-wise folding under UTF-8 the lead bytes of an upper/lower Cyrillic + // letter fold equal but the trail bytes do not, so this match was silently lost. + EXPECT_TRUE(isname("МЕЧ", "меч")); + EXPECT_TRUE(isname("Меч", "меч длинный")); + EXPECT_TRUE(isname("меч", "МЕЧ ДЛИННЫЙ")); +} + +TEST(TextSemantics, IsnameRejectsUnrelatedRussianWords) { + // Regression: byte-wise matching under UTF-8 compared only the shared leading byte of two + // different Cyrillic letters, so unrelated words matched each other. + EXPECT_FALSE(isname("щит", "меч длинный")); + EXPECT_FALSE(isname("меч", "щит деревянный")); + EXPECT_FALSE(isname("кольцо", "меч")); +} + +// ---------------------------------------------------------------------------- str_cmp + +TEST(TextSemantics, StrCmpIgnoresCase) { + EXPECT_EQ(str_cmp("abc", "ABC"), 0); + EXPECT_EQ(str_cmp("меч", "МЕЧ"), 0); + EXPECT_EQ(str_cmp(std::string("меч"), "МЕЧ"), 0); + EXPECT_NE(str_cmp("меч", "щит"), 0); +} + +TEST(TextSemantics, StrCmpOrdersConsistently) { + EXPECT_LT(str_cmp("abc", "abd"), 0); + EXPECT_GT(str_cmp("abd", "abc"), 0); + EXPECT_LT(str_cmp("ab", "abc"), 0); // prefix sorts first + EXPECT_GT(str_cmp("abc", "ab"), 0); +} + +TEST(TextSemantics, IsSamePrefixCountsCharactersNotBytes) { + EXPECT_TRUE(utils::IsSamePrefix("abcdef", "abcXXX", 3)); + EXPECT_FALSE(utils::IsSamePrefix("abcdef", "abXXXX", 3)); + + // Три буквы -- это три, а не шесть байт: пределы вроде kMinNameLength означают + // именно буквы, и байтовый счёт под UTF-8 давал вдвое короче (issue #3681). + EXPECT_TRUE(utils::IsSamePrefix("МЕЧ", "меч", 3)); + EXPECT_TRUE(utils::IsSamePrefix("мечник", "МЕЧТА", 3)); + EXPECT_FALSE(utils::IsSamePrefix("мечник", "МЕЧТА", 4)); + + // Строка короче запрошенного -- не совпадение, а не «совпало по остатку». + EXPECT_FALSE(utils::IsSamePrefix("ме", "меч", 3)); + EXPECT_TRUE(utils::IsSamePrefix("ме", "меч", 2)); +} + +// ---------------------------------------------------------------------------- argument splitting + +TEST(TextSemantics, OneArgumentLowercasesAscii) { + EXPECT_EQ(first_argument("LOOK north"), "look"); + EXPECT_EQ(first_argument(" Kill orc"), "kill"); +} + +TEST(TextSemantics, OneArgumentLowercasesRussian) { + // Regression: the byte table leaves UTF-8 Cyrillic untouched, so a Russian command argument + // would reach the command lookup unfolded and fail to match. + EXPECT_EQ(first_argument("СМОТРЕТЬ север"), "смотреть"); + EXPECT_EQ(first_argument("Убить орка"), "убить"); + EXPECT_EQ(first_argument("меч"), "меч"); +} + +TEST(TextSemantics, HalfChopSplitsAndLowercasesFirstWord) { + char arg1[kMaxInputLength] = {0}; + char arg2[kMaxInputLength] = {0}; + half_chop("СКАЗАТЬ привет всем", arg1, arg2); + EXPECT_STREQ(arg1, "сказать"); + EXPECT_STREQ(arg2, "привет всем"); +} + +// ---------------------------------------------------------------------------- GetCase + +TEST(TextSemantics, DeclensionOfFeminineNameEndingInYa) { + // Regression: under UTF-8 the byte-wise last-letter test never matched, so names stopped + // declining entirely and every case returned the nominative. + EXPECT_EQ(declension("Аня", EGender::kFemale, 1), "Ани"); + EXPECT_EQ(declension("Аня", EGender::kFemale, 2), "Ане"); + EXPECT_EQ(declension("Аня", EGender::kFemale, 3), "Аню"); + EXPECT_EQ(declension("Аня", EGender::kFemale, 4), "Аней"); + EXPECT_EQ(declension("Аня", EGender::kFemale, 5), "Ане"); +} + +TEST(TextSemantics, DeclensionOfMasculineNameEndingInConsonant) { + EXPECT_EQ(declension("Иван", EGender::kMale, 1), "Ивана"); + EXPECT_EQ(declension("Иван", EGender::kMale, 2), "Ивану"); + EXPECT_EQ(declension("Иван", EGender::kMale, 4), "Иваном"); + EXPECT_EQ(declension("Иван", EGender::kMale, 5), "Иване"); +} + +TEST(TextSemantics, DeclensionOfNameEndingInA) { + // The genitive/instrumental endings depend on the letter *before* the final one. + EXPECT_EQ(declension("Маша", EGender::kFemale, 1), "Маши"); // after ш -> и + EXPECT_EQ(declension("Анна", EGender::kFemale, 1), "Анны"); // otherwise -> ы + EXPECT_EQ(declension("Маша", EGender::kFemale, 4), "Машей"); // after ш -> ей + EXPECT_EQ(declension("Анна", EGender::kFemale, 4), "Анной"); // otherwise -> ой +} + +TEST(TextSemantics, DeclensionOfMasculineNameEndingInIShort) { + EXPECT_EQ(declension("Дрегвий", EGender::kMale, 1), "Дрегвия"); + EXPECT_EQ(declension("Дрегвий", EGender::kMale, 4), "Дрегвием"); +} + +// ---------------------------------------------------------------------------- fname + +TEST(TextSemantics, FnameExtractsFirstKeyword) { + EXPECT_STREQ(fname("sword long blade"), "sword"); + EXPECT_STREQ(fname("меч длинный"), "меч"); + EXPECT_STREQ(fname("кольцо"), "кольцо"); + EXPECT_STREQ(fname(""), ""); + EXPECT_STREQ(fname(" leading space"), ""); // stops at the very first non-letter +} + +TEST(TextSemantics, FnameStaysInsideItsBuffer) { + // fname() returns a fixed 30-byte buffer and used to copy without any bounds check; a long + // keyword (twice as many bytes per letter once the text is multibyte) ran past its end. + const char *const very_long = "оченьдлинноеключевоесловокотороенепомещается прочее"; + const char *const got = fname(very_long); + EXPECT_LT(std::strlen(got), 30u); + // Whatever was copied must be a prefix of the input, never mangled bytes. + EXPECT_EQ(std::string(very_long).compare(0, std::strlen(got), got), 0); +} + +// ---------------------------------------------------------------------------- cut_one_word + +TEST(TextSemantics, CutOneWordSplitsOnWordBoundaries) { + std::string rest = "меч длинный острый"; + std::string word; + cut_one_word(rest, word); + EXPECT_EQ(word, "меч"); + cut_one_word(rest, word); + EXPECT_EQ(word, "длинный"); + cut_one_word(rest, word); + EXPECT_EQ(word, "острый"); +} + +TEST(TextSemantics, CutOneWordHandlesAsciiAndEmpty) { + std::string rest = "take all"; + std::string word; + cut_one_word(rest, word); + EXPECT_EQ(word, "take"); + cut_one_word(rest, word); + EXPECT_EQ(word, "all"); + + std::string empty; + cut_one_word(empty, word); + EXPECT_TRUE(word.empty()); +} + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/tests/translit_koi8.cpp b/tests/translit_koi8.cpp new file mode 100644 index 0000000000..30382d2072 --- /dev/null +++ b/tests/translit_koi8.cpp @@ -0,0 +1,137 @@ +// Unit tests for the KOI8-R transliteration table (src/utils/translit_koi8.*, issue #3681). +// +// Pure ASCII: every non-ASCII fixture is spelled as UTF-8 byte escapes, so the test data does not +// depend on how an editor happens to save this file. + +#include "utils/translit_koi8.h" +#include "utils/native_text.h" +#include "utils/utf8.h" + +#include + +#include +#include + +using codepages::TranslitToKoi8; + +namespace { + +// Convenience: the replacement as a std::string, or "" so a failure prints readably. +std::string Tr(char32_t cp) { + const char *const r = TranslitToKoi8(cp); + return r == nullptr ? std::string("") : std::string(r); +} + +} // namespace + +TEST(TranslitKoi8, TypographyBecomesPlainAscii) { + EXPECT_EQ(Tr(0x2014), "-"); // EM DASH + EXPECT_EQ(Tr(0x2013), "-"); // EN DASH + EXPECT_EQ(Tr(0x2026), "..."); // HORIZONTAL ELLIPSIS + EXPECT_EQ(Tr(0x00AB), "\""); // LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + EXPECT_EQ(Tr(0x00BB), "\""); // RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + EXPECT_EQ(Tr(0x201C), "\""); // LEFT DOUBLE QUOTATION MARK + EXPECT_EQ(Tr(0x2019), "'"); // RIGHT SINGLE QUOTATION MARK + EXPECT_EQ(Tr(0x2122), "tm"); // TRADE MARK SIGN + EXPECT_EQ(Tr(0x00AD), ""); // SOFT HYPHEN: drops out entirely + // A no-break space needs no replacement: KOI8-R has one of its own (0x9A). + EXPECT_EQ(TranslitToKoi8(0x00A0), nullptr); +} + +TEST(TranslitKoi8, AccentedLatinLosesItsAccent) { + EXPECT_EQ(Tr(0x00E9), "e"); // e with acute + EXPECT_EQ(Tr(0x00FC), "u"); // u with diaeresis + EXPECT_EQ(Tr(0x00C0), "A"); // A with grave: case is kept + EXPECT_EQ(Tr(0x0141), "L"); // L with stroke (no NFKD decomposition; entered by hand) +} + +TEST(TranslitKoi8, NonRussianCyrillicMapsToItsRussianNeighbour) { + EXPECT_EQ(Tr(0x0404), "\xD0\xAD"); // Ukrainian Ye -> Russian E + EXPECT_EQ(Tr(0x0456), "\xD0\xB8"); // Ukrainian i -> Russian i + EXPECT_EQ(Tr(0x0490), "\xD0\x93"); // Ukrainian Ghe with upturn -> Russian Ghe +} + +TEST(TranslitKoi8, WhatKoi8AlreadyHasIsNotTouched) { + // ASCII and the whole Russian alphabet are representable, so there is nothing to replace + // and the table must stay out of the way. + for (char32_t cp = 0x20; cp < 0x7F; ++cp) { + EXPECT_EQ(TranslitToKoi8(cp), nullptr) << "ASCII " << static_cast(cp); + } + for (char32_t cp = 0x0410; cp <= 0x044F; ++cp) { + EXPECT_EQ(TranslitToKoi8(cp), nullptr) << "Cyrillic U+" << static_cast(cp); + } + EXPECT_EQ(TranslitToKoi8(0x0401), nullptr); // Yo + EXPECT_EQ(TranslitToKoi8(0x0451), nullptr); // yo + EXPECT_EQ(TranslitToKoi8(0x2500), nullptr); // box drawing: KOI8-R has these +} + +TEST(TranslitKoi8, NoEquivalentYieldsNullptr) { + EXPECT_EQ(TranslitToKoi8(0x1F600), nullptr); // grinning face + EXPECT_EQ(TranslitToKoi8(0x4E00), nullptr); // CJK ideograph + EXPECT_EQ(TranslitToKoi8(0x05D0), nullptr); // Hebrew alef +} + +TEST(TranslitKoi8, TableIsSortedAndUnique) { + // The lookup is a binary search; a table out of order would silently miss entries. + char32_t previous = 0; + unsigned found = 0; + for (char32_t cp = 1; cp <= 0x2FFF; ++cp) { + if (TranslitToKoi8(cp) != nullptr) { + EXPECT_LT(previous, cp); + previous = cp; + ++found; + } + } + EXPECT_GT(found, 200u) << "the generated table looks truncated"; +} + +TEST(TranslitKoi8, ToKoi8AppliesTheTable) { + // "-- privet ..." with an em dash and an ellipsis around Cyrillic text. + const std::string source = + "\xE2\x80\x94" " \xD0\xBF\xD1\x80\xD0\xB8\xD0\xB2\xD0\xB5\xD1\x82" "\xE2\x80\xA6"; + const std::string koi = native_text::to_koi8(source); + EXPECT_EQ(koi.front(), '-'); + EXPECT_EQ(koi.substr(koi.size() - 3), "..."); + // The Cyrillic in the middle survived as KOI8-R "privet". + EXPECT_EQ(koi, "- \xD0\xD2\xC9\xD7\xC5\xD4..."); +} + +TEST(TranslitKoi8, ToKoi8FallsBackToThePlaceholder) { + // An emoji has no KOI8-R equivalent at all, so it becomes the single placeholder character. + EXPECT_EQ(native_text::to_koi8("a\xF0\x9F\x98\x80" "b"), "a?b"); +} + +TEST(TranslitKoi8, GraphicsDegradeToWhatKoi8CanDraw) { + // Frames: the rounded and heavy corners reduce to the plain ones KOI8-R does have. + EXPECT_EQ(Tr(0x256D), "\xE2\x94\x8C"); // arc down and right -> U+250C + EXPECT_EQ(Tr(0x256E), "\xE2\x94\x90"); // arc down and left -> U+2510 + EXPECT_EQ(Tr(0x256F), "\xE2\x94\x98"); // arc up and left -> U+2518 + EXPECT_EQ(Tr(0x2570), "\xE2\x94\x94"); // arc up and right -> U+2514 + EXPECT_EQ(Tr(0x2501), "\xE2\x94\x80"); // heavy horizontal -> U+2500 + EXPECT_EQ(Tr(0x2503), "\xE2\x94\x82"); // heavy vertical -> U+2502 + // Partial blocks and stars reduce to something that still draws. + EXPECT_EQ(Tr(0x2589), "\xE2\x96\x88"); // 7/8 block -> full block + EXPECT_EQ(Tr(0x2726), "*"); // black four pointed star + EXPECT_EQ(Tr(0x2605), "*"); // black star +} + +TEST(TranslitKoi8, ReplacementNeverGrowsTheText) { + // The substitution is a pre-pass over the UTF-8, and callers rely on the conversion to + // KOI8-R never making the string longer. So a replacement must fit in the bytes the + // character itself occupied. + for (char32_t cp = 1; cp <= 0x10FFFF; ++cp) { + if (cp >= 0xD800 && cp <= 0xDFFF) { + continue; // surrogates are not encodable + } + const char *const r = TranslitToKoi8(cp); + if (r == nullptr) { + continue; + } + std::string source; + ASSERT_GT(utf8::encode(cp, source), 0u) << "U+" << static_cast(cp); + EXPECT_LE(std::strlen(r), source.size()) + << "replacement for U+" << static_cast(cp) << " is longer than the character"; + } +} + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/tests/utf8.cpp b/tests/utf8.cpp new file mode 100644 index 0000000000..d31f8be8f0 --- /dev/null +++ b/tests/utf8.cpp @@ -0,0 +1,166 @@ +// Unit tests for the character-semantic UTF-8 helpers (src/utils/utf8.*, issue #3681). +// +// This file is intentionally pure ASCII: every non-ASCII string is spelled out as explicit +// UTF-8 byte escapes so the test data is independent of the source file's ambient encoding +// (which is KOI8-R today and UTF-8 after the migration flip). Adjacent string literals are +// concatenated so an \xNN escape is never followed by a literal hex digit. + +#include "utils/utf8.h" + +#include + +#include +#include + +namespace { + +// "Privet" (Cyrillic) -- 6 letters, 12 bytes. +const char *const kPrivet = "\xD0\x9F\xD1\x80\xD0\xB8\xD0\xB2\xD0\xB5\xD1\x82"; +// "PRIVET" (all upper) and "privet" (all lower). +const char *const kPrivetUpper = "\xD0\x9F\xD0\xA0\xD0\x98\xD0\x92\xD0\x95\xD0\xA2"; +const char *const kPrivetLower = "\xD0\xBF\xD1\x80\xD0\xB8\xD0\xB2\xD0\xB5\xD1\x82"; + +std::string S(std::string_view v) { + return std::string(v); +} + +} // namespace + +TEST(Utf8, EmptyString) { + EXPECT_EQ(utf8::length(""), 0u); + EXPECT_TRUE(utf8::is_valid("")); + EXPECT_EQ(utf8::substr("", 0), ""); + EXPECT_EQ(utf8::substr("", 3, 5), ""); + EXPECT_EQ(S(utf8::char_at("", 0)), ""); + EXPECT_EQ(utf8::to_lower(std::string_view("")), ""); + EXPECT_EQ(utf8::byte_offset("", 4), 0u); +} + +TEST(Utf8, AsciiSemanticsMatchBytes) { + EXPECT_EQ(utf8::length("Hello"), 5u); + EXPECT_TRUE(utf8::is_valid("Hello, world!")); + EXPECT_EQ(utf8::to_lower(std::string_view("HeLLo")), "hello"); + EXPECT_EQ(utf8::to_upper(std::string_view("HeLLo")), "HELLO"); + EXPECT_EQ(utf8::substr("Hello", 1, 3), "ell"); + EXPECT_EQ(S(utf8::char_at("Hello", 1)), "e"); + EXPECT_EQ(S(utf8::char_at("Hello", 5)), ""); +} + +TEST(Utf8, CyrillicLengthIsCodePointsNotBytes) { + EXPECT_EQ(std::strlen(kPrivet), 12u); // sanity: the fixture really is 12 bytes + EXPECT_EQ(utf8::length(kPrivet), 6u); + EXPECT_TRUE(utf8::is_valid(kPrivet)); +} + +TEST(Utf8, CyrillicCaseFolding) { + EXPECT_EQ(utf8::to_lower(std::string_view(kPrivetUpper)), kPrivetLower); + EXPECT_EQ(utf8::to_upper(std::string_view(kPrivetLower)), kPrivetUpper); + // "Privet, Mir" -> "privet, mir" (mixed Cyrillic + ASCII punctuation). + const char *const mixed = "\xD0\x9F\xD1\x80\xD0\xB8\xD0\xB2\xD0\xB5\xD1\x82" ", " "\xD0\x9C\xD0\xB8\xD1\x80"; + const char *const mixed_lower = "\xD0\xBF\xD1\x80\xD0\xB8\xD0\xB2\xD0\xB5\xD1\x82" ", " "\xD0\xBC\xD0\xB8\xD1\x80"; + EXPECT_EQ(utf8::to_lower(std::string_view(mixed)), mixed_lower); +} + +TEST(Utf8, YoLetter) { + const char *const kYoUpper = "\xD0\x81"; // U+0401 (Yo) + const char *const kYoLower = "\xD1\x91"; // U+0451 (yo) + EXPECT_EQ(utf8::length(kYoUpper), 1u); + EXPECT_EQ(std::strlen(kYoUpper), 2u); + EXPECT_EQ(utf8::to_lower(std::string_view(kYoUpper)), kYoLower); + EXPECT_EQ(utf8::to_upper(std::string_view(kYoLower)), kYoUpper); + EXPECT_EQ(utf8::to_lower(0x0401u), 0x0451u); + EXPECT_EQ(utf8::to_upper(0x0451u), 0x0401u); +} + +TEST(Utf8, SubstrAndIndexOnCyrillic) { + // chars: [0]P [1]r [2]i [3]v [4]e [5]t + EXPECT_EQ(utf8::substr(kPrivet, 1, 3), "\xD1\x80\xD0\xB8\xD0\xB2"); // "riv" (chars 1..3) + EXPECT_EQ(utf8::substr(kPrivet, 4), "\xD0\xB5\xD1\x82"); // "et" to end + EXPECT_EQ(utf8::substr(kPrivet, 10), ""); // pos past end + EXPECT_EQ(S(utf8::char_at(kPrivet, 0)), "\xD0\x9F"); // char "P" + EXPECT_EQ(S(utf8::char_at(kPrivet, 5)), "\xD1\x82"); // char "t" + EXPECT_EQ(S(utf8::char_at(kPrivet, 6)), ""); // out of range + EXPECT_EQ(utf8::byte_offset(kPrivet, 2), 4u); + EXPECT_EQ(utf8::byte_offset(kPrivet, 6), 12u); + EXPECT_EQ(utf8::byte_offset(kPrivet, 100), 12u); +} + +TEST(Utf8, FourByteAndBom) { + const char *const kGrin = "\xF0\x9F\x98\x80"; // U+1F600 + EXPECT_EQ(utf8::length(kGrin), 1u); + EXPECT_EQ(std::strlen(kGrin), 4u); + EXPECT_TRUE(utf8::is_valid(kGrin)); + EXPECT_EQ(S(utf8::char_at(kGrin, 0)), kGrin); + EXPECT_EQ(utf8::sequence_length(0xF0), 4); + + const char *const kBom = "\xEF\xBB\xBF"; // U+FEFF + EXPECT_EQ(utf8::length(kBom), 1u); + EXPECT_TRUE(utf8::is_valid(kBom)); + char32_t cp = 0; + EXPECT_EQ(utf8::decode(kBom, 0, cp), 3u); + EXPECT_EQ(cp, 0xFEFFu); +} + +TEST(Utf8, SequenceLength) { + EXPECT_EQ(utf8::sequence_length(0x41), 1); // 'A' + EXPECT_EQ(utf8::sequence_length(0xD0), 2); + EXPECT_EQ(utf8::sequence_length(0xE0), 3); + EXPECT_EQ(utf8::sequence_length(0xF0), 4); + EXPECT_EQ(utf8::sequence_length(0x80), 1); // stray continuation + EXPECT_EQ(utf8::sequence_length(0xFF), 1); // out-of-range lead +} + +TEST(Utf8, DecodeBoundaries) { + char32_t cp = 0xABCD; + EXPECT_EQ(utf8::decode("", 0, cp), 0u); // empty + EXPECT_EQ(cp, 0u); + EXPECT_EQ(utf8::decode("A", 1, cp), 0u); // pos at end + EXPECT_EQ(utf8::decode("A", 0, cp), 1u); + EXPECT_EQ(cp, static_cast('A')); +} + +TEST(Utf8, EncodeRoundTrip) { + std::string out; + EXPECT_EQ(utf8::encode('A', out), 1u); + EXPECT_EQ(out, "A"); + out.clear(); + EXPECT_EQ(utf8::encode(0x041Fu, out), 2u); // U+041F (P) + EXPECT_EQ(out, "\xD0\x9F"); + out.clear(); + EXPECT_EQ(utf8::encode(0x1F600u, out), 4u); // U+1F600 + EXPECT_EQ(out, "\xF0\x9F\x98\x80"); + out.clear(); + EXPECT_EQ(utf8::encode(0xD800u, out), 0u); // surrogate rejected + EXPECT_TRUE(out.empty()); + EXPECT_EQ(utf8::encode(0x110000u, out), 0u); // above U+10FFFF + EXPECT_TRUE(out.empty()); +} + +TEST(Utf8, RejectsMalformed) { + EXPECT_FALSE(utf8::is_valid("\x80")); // lone continuation + EXPECT_FALSE(utf8::is_valid("\xD0")); // truncated 2-byte + EXPECT_FALSE(utf8::is_valid("Hi\xD0")); // truncated at end + EXPECT_FALSE(utf8::is_valid("\xC0\x80")); // overlong NUL (0xC0 lead) + EXPECT_FALSE(utf8::is_valid("\xC0\xAF")); // overlong '/' + EXPECT_FALSE(utf8::is_valid("\xE0\x80\xAF")); // overlong 3-byte + EXPECT_FALSE(utf8::is_valid("\xED\xA0\x80")); // U+D800 surrogate + EXPECT_FALSE(utf8::is_valid("\xF4\x90\x80\x80")); // U+110000, above range + EXPECT_FALSE(utf8::is_valid("\xF5\x80\x80\x80")); // 0xF5 lead +} + +TEST(Utf8, AcceptsRangeEdges) { + EXPECT_TRUE(utf8::is_valid("\xED\x9F\xBF")); // U+D7FF, just below surrogates + EXPECT_TRUE(utf8::is_valid("\xEE\x80\x80")); // U+E000, just above surrogates + EXPECT_TRUE(utf8::is_valid("\xF4\x8F\xBF\xBF")); // U+10FFFF, top of range + EXPECT_TRUE(utf8::is_valid("\xC2\x80")); // U+0080, smallest 2-byte +} + +TEST(Utf8, LenientCountingAndFolding) { + // Malformed bytes are counted as one code point each and passed through by the folders, + // so nothing is dropped when the helpers meet non-UTF-8 (e.g. legacy KOI8-R) data. + EXPECT_EQ(utf8::length("\x80\x80"), 2u); + EXPECT_EQ(utf8::to_lower(std::string_view("\x80\x80")), "\x80\x80"); + EXPECT_EQ(utf8::to_upper(std::string_view("A\xFF" "Z")), "A\xFF" "Z"); +} + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/tests/utils.encoding.cpp b/tests/utils.encoding.cpp index 8716438155..84e28dc7cb 100644 --- a/tests/utils.encoding.cpp +++ b/tests/utils.encoding.cpp @@ -119,11 +119,13 @@ TEST(Utils_Encoding, Utf8ToKoi_YoLowercase) TEST(Utils_Encoding, Utf8ToKoi_UnknownCharReplacement) { - // Characters not in KOI8-R should be replaced with KOI8_UNKNOWN_CHAR (+) + // Characters not in KOI8-R should be replaced with KOI8_UNKNOWN_CHAR (?) // For example, Euro sign: U+20AC = UTF-8 E2 82 AC + // (This is the raw converter. native_text::to_koi8 runs the transliteration table first, + // so through that path the euro sign becomes "EUR" and never reaches the placeholder.) std::string utf8_euro = "\xE2\x82\xAC"; std::string koi_euro = Utf8ToKoi(utf8_euro.c_str()); - EXPECT_EQ("+", koi_euro); + EXPECT_EQ("?", koi_euro); } TEST(Utils_Encoding, Utf8ToKoi_BoxDrawingChars) diff --git a/tests/where.format.cpp b/tests/where.format.cpp index 99810445ba..e0f2b6d3ad 100644 --- a/tests/where.format.cpp +++ b/tests/where.format.cpp @@ -6,6 +6,7 @@ // строки == ширине колонки, и индексная арифметика ниже корректна. #include "engine/ui/cmd/do_where.h" +#include "utils/native_text.h" #include @@ -92,14 +93,23 @@ TEST(WhereFormat, LocationColumnAligned) { const auto lines = SplitLines(FormatWhere(SampleRows())); ASSERT_GE(lines.size(), 5u); - const auto ref = lines[1].rfind(" - "); + // Колонка разделителя измеряется в СИМВОЛАХ, а не в байтах: под UTF-8 байтовое смещение + // зависит от того, сколько в имени кириллицы, и "выровнено" перестаёт значить "в одной + // колонке" (issue #3681). + const auto column_of_separator = [](const std::string &line) { + const auto at = line.rfind(" - "); + return at == std::string::npos + ? std::string::npos : native_text::char_count(std::string_view(line).substr(0, at)); + }; + + const auto ref = column_of_separator(lines[1]); ASSERT_NE(ref, std::string::npos); - EXPECT_EQ(lines[2].rfind(" - "), ref) << "предмет с именем ровно 25 симв."; - EXPECT_EQ(lines[3].rfind(" - "), ref) << "строка-продолжение контейнера"; - EXPECT_EQ(lines[4].rfind(" - "), ref) << "короткое имя"; + EXPECT_EQ(column_of_separator(lines[2]), ref) << "предмет с именем ровно 25 симв."; + EXPECT_EQ(column_of_separator(lines[3]), ref) << "строка-продолжение контейнера"; + EXPECT_EQ(column_of_separator(lines[4]), ref) << "короткое имя"; // Имя моба (29 симв.) длиннее поля в 25 -> разделитель уезжает вправо на 4. - EXPECT_EQ(lines[0].rfind(" - "), ref + 4); + EXPECT_EQ(column_of_separator(lines[0]), ref + 4); } // Предмет в контейнере даёт ровно две строки: первая оканчивается на diff --git a/tools/audit_utf8_migration.py b/tools/audit_utf8_migration.py new file mode 100755 index 0000000000..02cdd798cf --- /dev/null +++ b/tools/audit_utf8_migration.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Audit byte-vs-char assumptions ahead of the KOI8-R -> UTF-8 migration (issue #3681). + +The engine currently assumes "1 byte == 1 character" in three shapes: length/size used as a +character count, fixed byte-offset access/truncation, and per-byte case/classification. Under +UTF-8 every one of those breaks on multibyte (Cyrillic) text. This script scans the C++ sources, +classifies the suspect call sites into categories, and *prioritises files that actually contain +non-ASCII (Russian) bytes* -- those are where a byte-vs-char bug is observable. + +It is a triage aid, not a linter: every hit needs a human to decide whether it means bytes +(fine) or characters (needs utf8::). Source files are KOI8-R today, so snippets are decoded from +KOI8-R for readable output. + +Usage: + tools/audit_utf8_migration.py [PATHS ...] # default: src + tools/audit_utf8_migration.py --full # list hits in ASCII-only files too + tools/audit_utf8_migration.py --category substr # only one category (repeatable) + tools/audit_utf8_migration.py --list-categories +""" + +import argparse +import os +import re +import sys + +# Each category: (regex over a latin-1-decoded line, one-line description). +# The regexes are deliberately broad -- false positives are cheap, missed sites are not. +CATEGORIES = { + "printf-width": ( + re.compile(r"%[-+ 0#]*(?:\d+|\*)(?:\.(?:\d+|\*))?s|%\.(?:\d+|\*)s"), + "printf width/precision on a string (%-20s, %.*s) -- pads/truncates by bytes", + ), + "substr": ( + re.compile(r"\.substr\s*\("), + "substr() -- byte offsets, can cut a code point in half", + ), + "rel-index": ( + re.compile(r"\[[^\]]*-\s*[123]\s*\]"), + "indexing relative to length (str[len-1]) -- last byte, not last character", + ), + "strchr-cyr": ( + re.compile(r'strchr\s*\(\s*"([^"]*)"'), + "strchr() over a literal that contains Cyrillic -- matches a byte, not a letter", + ), + "case-deref": ( + re.compile(r"\b(?:UPPER|LOWER)\s*\(\s*(?:\*|\w+\s*\[)"), + "UPPER/LOWER on a dereferenced/indexed byte -- mangles a UTF-8 lead byte", + ), + "fixed-copy": ( + re.compile(r"\b(?:strn?cpy|strncat)\s*\("), + "strcpy/strncpy/strncat into a fixed buffer -- byte length may split a character", + ), + "strlen": ( + re.compile(r"\bstrlen\s*\("), + "strlen() -- byte count; suspect only when used as a character/display count", + ), + "size-length": ( + re.compile(r"\.(?:size|length)\s*\(\s*\)"), + "std::string size()/length() -- byte count used as a character count", + ), +} + +# High-volume categories: reported in the summary, but only listed for Cyrillic-bearing files +# unless --full, to keep the output actionable. +HIGH_VOLUME = {"strlen", "size-length"} + +SOURCE_EXTENSIONS = (".cpp", ".h", ".hpp", ".cc", ".cxx") + + +def has_cyrillic(raw: bytes) -> bool: + """True if the file carries any high-bit byte (KOI8-R Russian text lives at >= 0x80).""" + return any(b >= 0x80 for b in raw) + + +def literal_has_high_byte(fragment: str) -> bool: + return any(ord(c) >= 0x80 for c in fragment) + + +def decode_snippet(line: str) -> str: + """`line` is latin-1 (bytes 1:1); re-render it from KOI8-R so Russian reads correctly.""" + return line.encode("latin-1", "replace").decode("koi8-r", "replace").rstrip() + + +def iter_source_files(paths): + for path in paths: + if os.path.isfile(path): + yield path + continue + for root, _dirs, files in os.walk(path): + if "third_party_libs" in root: + continue + for name in files: + if name.endswith(SOURCE_EXTENSIONS): + yield os.path.join(root, name) + + +def scan_file(path, wanted): + with open(path, "rb") as handle: + raw = handle.read() + cyrillic = has_cyrillic(raw) + text = raw.decode("latin-1") + hits = [] # (category, lineno, snippet) + for lineno, line in enumerate(text.splitlines(), 1): + for category, (pattern, _desc) in CATEGORIES.items(): + if category not in wanted: + continue + match = pattern.search(line) + if not match: + continue + if category == "strchr-cyr" and not literal_has_high_byte(match.group(1)): + continue + hits.append((category, lineno, decode_snippet(line))) + return cyrillic, hits + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Audit byte-vs-char sites for the UTF-8 migration.") + parser.add_argument("paths", nargs="*", default=["src"], help="files or directories (default: src)") + parser.add_argument("--category", action="append", dest="categories", + help="restrict to a category (repeatable); default: all") + parser.add_argument("--full", action="store_true", + help="also list hits in ASCII-only files and high-volume categories") + parser.add_argument("--list-categories", action="store_true", help="print category names and exit") + args = parser.parse_args(argv) + + if args.list_categories: + for name, (_re, desc) in CATEGORIES.items(): + print(f"{name:<14} {desc}") + return 0 + + wanted = set(args.categories) if args.categories else set(CATEGORIES) + unknown = wanted - set(CATEGORIES) + if unknown: + parser.error(f"unknown category: {', '.join(sorted(unknown))}") + + # counts[category] = [hits_in_cyrillic_files, hits_in_ascii_files] + counts = {name: [0, 0] for name in CATEGORIES} + listing = [] # (priority, path, category, lineno, snippet) + + for path in sorted(iter_source_files(args.paths)): + cyrillic, hits = scan_file(path, wanted) + for category, lineno, snippet in hits: + counts[category][0 if cyrillic else 1] += 1 + show = cyrillic or args.full + if category in HIGH_VOLUME and not args.full: + show = False + if show: + listing.append((0 if cyrillic else 1, path, category, lineno, snippet)) + + print("=" * 78) + print("byte-vs-char audit (Cyr = files with Russian text -> where bugs are observable)") + print("=" * 78) + print(f"{'category':<14}{'Cyr':>8}{'ASCII':>8} description") + print("-" * 78) + for name, (_re, desc) in CATEGORIES.items(): + if name not in wanted: + continue + c_cyr, c_ascii = counts[name] + flag = " [high-volume]" if name in HIGH_VOLUME else "" + print(f"{name:<14}{c_cyr:>8}{c_ascii:>8} {desc}{flag}") + print("-" * 78) + total_cyr = sum(c[0] for c in counts.values()) + total_ascii = sum(c[1] for c in counts.values()) + print(f"{'TOTAL':<14}{total_cyr:>8}{total_ascii:>8}") + print() + + listing.sort(key=lambda row: (row[0], row[1], row[3])) + current_file = None + for _priority, path, category, lineno, snippet in listing: + if path != current_file: + current_file = path + print(f"\n### {path}") + print(f" {lineno:>6} [{category}] {snippet}") + + if not args.full: + print("\n(high-volume categories and ASCII-only files hidden; re-run with --full)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/meson/check_sources_utf8.py b/tools/meson/check_sources_utf8.py new file mode 100755 index 0000000000..999608c171 --- /dev/null +++ b/tools/meson/check_sources_utf8.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Убедиться, что исходники в рабочем дереве -- UTF-8 (issue #3681). + +Флип на UTF-8 сделан снятием working-tree-encoding с /src и /tests в .gitattributes, +а это преобразование времени checkout'а. Дерево, выкаченное до флипа, при переключении +на ветку остаётся в KOI8-R: содержимое блоба не изменилось, и git молча оставляет файлы +как есть -- git status показывает чистое дерево. + +Собранный из такого дерева бинарь получает кои-восьмые строковые литералы, а движок +считает весь текст нативным UTF-8. Дальше расходится всё: сравнения имён, доски, +кодировка лога, а конверсия на диск гонит такие литералы через словарь транслита. + +Чинится принудительной перевыкачкой: rm -rf src tests && git checkout -- src tests +""" + +import sys +from pathlib import Path + +SUFFIXES = {".cpp", ".h", ".hpp", ".cc", ".inl"} + + +def main() -> int: + root = Path(sys.argv[1] if len(sys.argv) > 1 else ".") + bad = [] + for directory in ("src", "tests"): + base = root / directory + if not base.is_dir(): + continue + for path in base.rglob("*"): + if path.suffix not in SUFFIXES or not path.is_file(): + continue + try: + path.read_bytes().decode("utf-8") + except UnicodeDecodeError: + bad.append(path.relative_to(root)) + + if not bad: + return 0 + + print( + f"исходники не в UTF-8: {len(bad)} файлов.\n" + "\n" + "Похоже, рабочее дерево выкачено до флипа на UTF-8 (issue #3681): флип сделан\n" + "снятием working-tree-encoding в .gitattributes, а это преобразование времени\n" + "checkout'а. При переключении ветки git оставляет такие файлы в KOI8-R и считает\n" + "дерево чистым -- git status ничего не покажет. Собранный отсюда бинарь получит\n" + "кои-восьмые строковые литералы, а движок считает весь текст нативным UTF-8.\n" + "\n" + "Лечится перевыкачкой:\n" + " rm -rf src tests && git checkout -- src tests\n" + "\n" + "Например: " + ", ".join(str(x) for x in sorted(bad)[:4]) + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/meson/generate_version.py b/tools/meson/generate_version.py index 851fef3164..5cb2ce0c87 100755 --- a/tools/meson/generate_version.py +++ b/tools/meson/generate_version.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import sys +import os import subprocess from datetime import datetime @@ -20,6 +21,11 @@ except Exception: pass +# Сборка может идти там, где git недоступен или бесполезен: контейнер, worktree (у него .git -- +# файл-ссылка наружу контекста), распакованный архив. Тогда значения пробрасываются окружением. +if not git_rev or git_rev == 'unknown': + git_rev = os.environ.get('BYLINS_GIT_REV', '') or git_rev or 'unknown' + # issue.versioning: the 4th version part ("release") = total commit count -- monotonic, # +1 per commit. Computed at build time like the revision hash. If git is unavailable # (e.g. build from an unpacked archive) it falls back to 0, mirroring git_rev above. @@ -34,6 +40,9 @@ except Exception: pass +if commit_count == "0": + commit_count = os.environ.get('BYLINS_COMMIT_COUNT', '') or "0" + # major.minor.patch: explicit, from the repo-root VERSION file (single source of truth). try: with open(f'{source_root}/VERSION.txt', encoding='ascii') as vf: @@ -44,7 +53,7 @@ engine_name = "BRusMUD" engine_version = f'{mmp}.{commit_count}' -with open(input_file, encoding='koi8-r') as f: +with open(input_file, encoding='utf-8') as f: content = f.read() content = content.replace('${REVISION}', f'{git_rev} ({buildtype})') @@ -54,5 +63,5 @@ content = content.replace('${ENGINE_NAME}', engine_name) content = content.replace('${ENGINE_VERSION}', engine_version) -with open(output_file, 'w', encoding='koi8-r') as f: +with open(output_file, 'w', encoding='utf-8') as f: f.write(content)