Пепе из лотерейных билетов - это максимум, на что эмулятор был способен пару месяцев назад
Пепе из лотерейных билетов - это максимум, на что эмулятор был способен пару месяцев назад

Привет, Хабр!

Прошло лет 20 с выхода “первой русской ММОРПГ” Сферы, большинство ресурсов мертвы, база данных по игре доступна только частями в вебархиве, форумы закрыты, онлайн полтора человека. Самое время разбираться, как она устроена, правда?

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

Если бы не Соул, sirAgil, grandlegion и многие другие, мне бы никогда не пришло в голову, что этот проект вообще возможен. Огромное вам спасибо за работу и комьюнити! (Где бы результаты этой работы достать теперь…)

Весь код проекта написан на C#, для игрового сервера используется Godot. Сфера плохо справляется с большим количеством игроков на сервере (максимум 100-150), поэтому производительность нас особо не беспокоит. Сам клиент довольно быстро деградирует до секунд на кадр:

Видео с генерацией КДПВ

Чиним клиент

Обычно игра запускается лончером и выдает ошибку, если открывать клиент напрямую. Лончер умеет гораздо больше, чем нам сейчас нужно, и разбираться с 2FA и привязкой к устройству не хотелось. Раньше интерфейс логина был прямо в клиенте - может, нам повезет, и разработчики ничего не удаляли? Текст ошибки должен быть где-то в файлах игры.

К этому моменту я успешно забыл, что текстовые файлы зашифрованы, но все оказалось гораздо проще - нужная строка лежит прямо в sphereclient.exe. Откроем его в дебаггере (x86dbg или любом другом). В том же блоке кода есть переход 75 F6, после которого показывается ошибка. Попробуем самое простое - поменять условие на противоположное.

Текст ошибки в sphereclient.exe (x86dbg)
Текст ошибки в sphereclient.exe (x86dbg)

Это работает, но не совсем, видим новую ошибку. Проделаем еще раз то же самое (или заранее заметим код чуть выше перехода, который мы поправили, если вы не я). На этот раз клиент открылся и показывает интерфейс логина. Поле для ввода пароля можно отредактировать в effects\connection.ui:313, чтобы символы не закрывались звездочками. Самое время разбираться с пакетами.

Пакеты для анализа взяты прямо с лайв-серверов, клиент спокойно относится к реплею, если пакет проходит минимально необходимую валидацию. Чаще всего достаточно передать правильный индекс игрока (тот же самый, что и при логине). Для пакетов, производящих какие-нибудь действия с игровым объектом, добавляется еще индекс этого объекта. 

Примечание: сервер отправляет пакеты в открытом виде, а расшифровку клиентских я пропущу, т.к. лайв-сервера еще доступны. В исследовательских целях можно пользоваться готовым файлом, который будет в проекте на гитхабе. Я добавил в него ранний выход из метода энкодинга (ищется по цепочке вызовов со стандартным send).

Логинимся и выбираем персонажа

Окно логина
Окно логина

Первичный обмен данными выглядит так:

1. Клиент инициирует TCP соединение

2. После установки соединения сервер отправляет пакет готовности к работе

3. Сервер отправляет информацию о себе

4. Клиент отправляет логин-пароль

5. Сервер проверяет логин-пароль

6. Сервер отправляет пакет инициализации выбора персонажа и информацию о персонажах

7. Клиент удаляет, выбирает существующего персонажа или создает нового

8. Сервер проверяет имя на уникальность (если создается новый) и отправляет пакет входа в игру

Пакеты

Структура большинства серверных пакетов: 

младший байт длины, старший байт длины, 0x2C, 0x01, 00, синхронизационный байт, 
синхронизационный байт, старший байт индекса клиента, младший байт индекса клиента, 
0x08, 0x40, старший байт типа пакета, младший байт типа пакета, содержимое.

Для большинства клиентских пакетов: 

младший байт длины, старший байт длины, синхронизационный байт, 
синхронизационный байт, синхронизационный байт, синхронизационный байт, 0x2C, 
0x01, 0x00, синхронизационный байт, синхронизационный байт, 0x08, 0x40, 
старший байт типа пакета, младший байт типа пакета, содержимое.

0x2C 0x01 - это маркер корректности пакета, любые другие значения вместо него заставляют клиент полностью игнорировать пакет.

Синхронизационные байты, несмотря на название, можно передавать какие угодно, например 0x00.

Для индекса клиента подходит любое значение из диапазона unsigned short, кроме нуля (1 - 0xFFFF). Иногда единица почему-то не желает работать, поэтому безопасный вариант - начинать с двойки.

Информации о типах пакетов у меня пока не хватает. К счастью, как мы увидим в следующих статьях, пакеты одного типа почти всегда имеют одинаковую длину и структуру.

Сервер - готов к работе

Статичный пакет.

{ 0x0A, 0x00, 0xC8, 0x00, 0x14, 0x05, 0x00, 0x00, 0x1F, 0x42 }

Сервер - информация о себе

Индекс клиента (unsigned short) и текущее время в игровом мире. Здесь и далее - под спойлером описание генерации пакета и энкодинга нужных полей. Все биты от старших к младшим (т.е. 1 - самый старший бит).

{
    0x38, 0x00, 0x2C, 0x01, 0x00, 0x00, 0x04, MajorByte(playerIndex), MinorByte(playerIndex), 0x08, 0x40, 
    0x20, 0x10, currentSphereTime[0], currentSphereTime[1], currentSphereTime[2], currentSphereTime[3], 
    currentSphereTime[4], 0x7C, 0x12, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1A, 0x3B, 
    0x12, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
    0x00, 0x00, 0x8D, 0x9D, 0x01, 0x00, 0x00, 0x00
};
Подробнее

Время в Сфере начинается с 00:00:00 01/01/7800 и идет в 12 раз быстрее реального. За начало времен я брал момент, когда дописал метод энкодинга, можно подставить любой другой.

public const int UnixTimeOrigin = 1649722100;
var sphereDate = new DateTime().AddSeconds((new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds() - UnixTimeOrigin) * 12);

Поля, как и во всех остальных пакетах, bit-packed. В самом массиве:

1. 4 бита минут, 4 бита игнорируем. Возможно, это секунды, но в клиенте изменений я не заметил

2. 1 бит дней, 5 бит часов, 2 бита минут

3. 4 бита месяцев, 4 бита дней

4. 8 бит лет

5. 7 бит игнорируем, 1 бит лет

Изменение второго бита вызывает ошибку сессии авторизации. Никаких других проверок мне пока найти не удалось, поэтому вернемся к этому когда-нибудь, а пока оставляем второй бит нулевым на ближайшие 20 лет реального времени.

Клиент - логин и пароль

Зашифрованные логин и пароль

Для индекса игрока 4F6F, логина “a1b2c3d4” и пароля “1” (оба без кавычек) выглядит примерно так:

1E 00 19 BD D8 01 2C 01 00 72 00 4F 6F 08 40 40 31 FC 87 C5 88 C9 8C CD 90 D1 00 C4 00 00
Подробнее

Логин и пароль начинаются с 18 байта в пакете, разделены 0x00 или 0x01. Пароль всегда следует после логина и разделителя, заканчивается 0x00. Клиент позволяет указать в поле ввода любые символы из Win1251, но обрежет из них какую-то сложно прогнозируемую часть при отправке, поэтому для простоты мы ограничимся расшифровкой цифр и букв.

Чтобы декодировать логин и пароль, сначала отнимем от первого байта логина 3 и прибавим к первому байту пароля 1. Я подозреваю, что над Сферой работали большие любители шутки про 59 спичек, но доказательств у меня нет.

Дальше и для логина, и для пароля алгоритм одинаковый. Если встречается четное число, клиент на этом месте отправил букву, если нечетное - цифру.

Буквы: (char) (encoded[i] / 4 - 1 + ‘A’)

Цифры: (char) (encoded[i] / 4 - 48 + ‘0’)

Сервер - инициализация выбора персонажа

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

{
    0x52, 0x00, 0x2C, 0x01, 0x00, 0x00, 0x04, MajorByte(playerIndex), MinorByte(playerIndex), 0x08, 0x40, 
    0x80, 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
    0x00, 0x00, 0x00
};
Погуляем по стартовому экрану

Сервер - экран выбора персонажа

Почти полные данные о персонаже, не передается информация о клане, специальности и точном количестве кармы. Для новых персонажей отправляется типовой пакет, в котором можно поменять любые статы и стандартное имя ( с “- пусто - “). Здесь и далее одинаковые названия с разными номерами (например, name1 - name19) означают соответствующий по номеру байт, в котором лежит значение.

{
    0x6C, 0x00, 0x2C, 0x01, 0x00, 0x00, 0x04, MajorByte(PlayerIndex), MinorByte(PlayerIndex), 0x08, 0x40, 
    0x60, lookType, hpMax1, hpMax2, mpMax1, mpMax2, strength1, strenth2, agility1, agility2, accuracy1, 
    accuracy2, endurance1, endurance2, earth1, earth2, air1, air2, water1, water2, fire1, fire2, pdef1, 
    pdef2, mdef1, mdef2, karma1, satietyMax1, satietyMax2, titleLvl1, titleLvl2, degreeLvl1, degreeLvl2, 
    titleXp1, titleXp2, titleXp3, titleXp4, degreeXp1, degreeXp2, degreeXp3, degreeXp4, satietyCurrent1, 
    satietyCurrent2, hpCurrent1, hpCurrent2, mpCurrent1, mpCurrent2, titleStats1, titleStats2, degreeStats1, 
    degreeStats2, degreeStats3, 0xC0, 0xC8, 0xC8, isFemale1, name1, name2, name3, name4, name5, name6, 
    name7, name8, name9, name10, name11, name12, name13, name14, name15, name16, name17, name18, name19, 
    face1, hairStyle1, hairColor1, tattoo1, bootsModelId, pantsModelId, armorModelId, helmetModelId, 
    glovesModelId1, glovesModelId2, 0xC0, 0xC0, 0x00, 0xFC, 0xFF, 0xFF, 0xFF, isNotDeleted1, 0x00, 0x00, 
    0x00, 0x00
};

Имя энкодится в WIn1251 и дополняется до 19 байт. Заметь я правильную кодировку в нужный момент, сэкономил бы несколько дней головной боли.

Подробное описание содержимого каждого байта писать не буду, чтобы не раздувать статью, а для интересующихся в конце есть ссылка на гитхаб. Все поля bit-packed и могут содержать биты сразу нескольких статов (пример выше в энкодинге времени) или константные значения. Проверки на корректность полей зачастую нет, неправильные значения могут крашнуть клиент или заставить его показать неправильную текстовую строку:

Карма немного пострадала
Карма немного пострадала

Типовой пакет для нового персонажа со стандартным именем и статами:

{
    0x6C, 0x00, 0x2C, 0x01, 0x00, 0x00, 0x04, MajorByte(playerIndex), MinorByte(playerIndex), 0x08, 0x40, 
    0x60, 0x79, 0x91, 0x01, 0x90, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC8, 0x00, 0x90, 0x01, 0x90, 0x01, 0x10, 0x00, 0x10, 0x00, 
    0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
    0x00, 0x00, 0x00, 0xFC, 0xFF, 0xFF, 0xFF, 0x03, 0x00, 0x00, 0x00, 0x00
};

Клиент - удаление персонажа

Индекс удаляемого персонажа и его имя

2A 00 D4 42 E2 01 2C 01 00 26 02 4F 6F 08 40 A0 61 08 00 00 00 C4 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 C0 00

Лайв-сервера отправляют клиент к окну логина после удаления персонажа, но на моем эмуляторе это вызывает забавный баг, с которым я пока не справился. Клиент, видимо, не очищает память, и рисует сразу несколько моделей одновременно.

Пакет для повторного подключения:

{ 0x0A, 0x00, 0xC8, 0x00, 0x94, 0x05, 0x00, 0x00, 0x2F, 0x64 }
Видео с багом

Клиент - создание персонажа

Имя создаваемого персонажа, пол, выбранный внешний вид. Для мужского персонажа стандартной внешности с именем “1” выглядит примерно так:

1B 00 8F BD 8C 07 2C 01 00 88 A6 4F 6F 08 40 80 05 08 04 8E 41 0C 00 0C 0C 0C 0C
Подробнее

Имя начинается с 20-го байта и заканчивается на 6-ом байте с конца. Последние 5 байт - это внешность и пол персонажа. Перед отправкой имя энкодится, на этот раз не похоже на win1251. У совпадающих по виду первой русской или английской буквы итоговые коды одинаковые, поэтому я до сих пор не понимаю, как правильно разделять ники на разных языках. Для остальных букв все нормально. Будем предполагать, что если вторая буква русская, то и первая должна быть русская.

Для английских букв (код < 129): (char) (name[i] / 2)

Для русских строчных букв (код >= 193, ‘а’ в формуле - русская): (char) ((name[i] - 192) / 2 + ‘а’)

Для русских прописных букв: ((name[i] - 129) / 2 + ‘А’)

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

Лицо: 256 - client_val

Остальные: 255 - client_val

Клиент - выбор персонажа

Порядковый номер выбранного слота.

15 00 84 42 F3 01 2C 01 00 DC 03 4F 6F 08 40 80 05 04 04 08 00

На сервере: charIndex = buffer[17] / 4 - 1

Сервер - имя уже используется

Статичный пакет с индексом клиента

{
    0x0E, 0x00, 0x2C, 0x01, 0x00, 0x00, 0x04, MajorByte(playerIndex),
    MinorByte(playerIndex), 0x08, 0x40, 0x00, 0x01, 0x00
};

Сервер - проверка имени успешна

Статичный пакет с индексом клиента

{
    0x0E, 0x00, 0x2C, 0x01, 0x00, 0x00, 0x04, MajorByte(playerIndex),
    MinorByte(playerIndex), 0x08, 0x40, 0x80, 0x00, 0x00
};

Сервер - вход в игру

Имя персонажа (еще раз) и его длина, внутриигровые координаты, имя клана и его длина, занятые слоты инвентаря, надетых на персонажа вещей и спецслоты, текущее и максимальное хп, тип кармы, уровни, специальность и ее уровень, количество денег.

Перед описанием пакета нужно разбираться, как в Сфере устроена отправка координат (сюрприз - её минимум 3 разных вида), оставим это для следующей части

Бонус: распаковка текстовых файлов

01 Дверь
10 Дверь открывается ключом.
01 Дверь 10 Дверь открывается ключом.

В упакованных текстовых файлах хранятся текстовые строки, статы монстров, вещи и прочие интересности. Нам они понадобятся для пакетов с игровыми объектами в следующих частях.

Каждый файл начинается с префикса SPHR. По этому префиксу в дебаггере находится код упаковки, делающий XOR части содержимого файла с восьмым и четырнадцатым байтами:

Проделаем обратную операцию:

var xor_8 = fileContents[8];
var xor_14 = fileContents[14];
fileContents[9] ^= xor_8;
fileContents[17] ^= xor_8;
fileContents[20] ^= xor_8;
fileContents[4] ^= xor_14;
fileContents[5] ^= xor_14;
fileContents[6] ^= xor_14;
fileContents[7] ^= xor_14;

Байты 8 и 9 содержимого файла - это магическое число, указывающее на его тип, в нашем случае ZLIB с минимальной компрессией. Оказывается, стандартная реализация DeflateStream в .NET просто не желает их переваривать, и можно взять SharpZipLib, а не писать все на C++. 

Полный код распаковки всех файлов
using System.Text;
using ICSharpCode.SharpZipLib.Core;
using ICSharpCode.SharpZipLib.Zip.Compression.Streams;

if (args.Length < 2)
{
    Console.WriteLine("Usage: sphParamDecode.exe <input_path> <output_path>");
    Environment.Exit(1);
}

var inputPath = args[0];

if (!Directory.Exists(inputPath))
{
    Console.WriteLine($"Directory not found for input_path: {inputPath}");
    Environment.Exit(1);
}

var outputPath = args[1];

Directory.CreateDirectory(outputPath);

var fileList = Directory.EnumerateFiles(inputPath, "*.*", SearchOption.AllDirectories);

var buffer = new byte[1024];

Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var win1251 = Encoding.GetEncoding(1251);

foreach (var filePath in fileList)
{
    try
    {
        var fileContents = File.ReadAllBytes(filePath);

        if (fileContents.Length < 4)
        {
            continue;
        }

        var sphrMarker = win1251.GetString(fileContents[..4]);

        if (!sphrMarker.Equals("SPHR"))
        {
            continue;
        }

        var xor_8 = fileContents[8];
        var xor_14 = fileContents[14];
        fileContents[9] ^= xor_8;
        fileContents[17] ^= xor_8;
        fileContents[20] ^= xor_8;
        fileContents[4] ^= xor_14;
        fileContents[5] ^= xor_14;
        fileContents[6] ^= xor_14;
        fileContents[7] ^= xor_14;

        var ms = new MemoryStream(fileContents[8..]);
        var inflaterStream = new InflaterInputStream(ms);
        
        var fileName = Path.GetFileName(filePath);
        var relativePath = Path.GetRelativePath(inputPath, filePath);
        var currentDirectory = Path.GetDirectoryName(relativePath);
        var outputDirectoryPath = Path.Combine(outputPath, currentDirectory);
        Directory.CreateDirectory(outputDirectoryPath);

        var outputFilePath = Path.Combine(outputDirectoryPath, fileName);
        var outputFile = File.Open(outputFilePath, FileMode.Create);
        StreamUtils.Copy(inflaterStream, outputFile, buffer);
        outputFile.Close();
        
        Console.WriteLine("Processed: " + relativePath);
    }
    catch (IOException e)
    {
    }
} 

Упаковываем обратно
using System.Text;
using ICSharpCode.SharpZipLib.Zip.Compression;
using ICSharpCode.SharpZipLib.Zip.Compression.Streams;

if (args.Length < 2)
{
    Console.WriteLine("Usage: sphParamEncode.exe <input_file_path> <output_file_path>");
    Environment.Exit(1);
}

var inputPath = args[0];

if (!File.Exists(inputPath))
{
    Console.WriteLine($"File not found: {inputPath}");
    Environment.Exit(1);
}

var outputPath = args[1];

Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var win1251 = Encoding.GetEncoding(1251);

var inputFileBytes = File.ReadAllBytes(inputPath);
var outputFile = File.Open(outputPath, FileMode.Create);
var inputMemoryStream = new MemoryStream();

var deflaterStream = new DeflaterOutputStream(inputMemoryStream, new Deflater(1));
deflaterStream.Write(inputFileBytes);
deflaterStream.Close();

var inputBuffer = inputMemoryStream.ToArray();
inputBuffer[1] ^= 0x78;
inputBuffer[9] ^= 0x78;
inputBuffer[12] ^= 0x78;

var outputFileWriter = new StreamWriter(outputFile, win1251);
var crcOrSmth = new byte [4];
crcOrSmth[0] = 0x00;
crcOrSmth[1] = 0x00;
crcOrSmth[2] = inputBuffer[6];
crcOrSmth[3] = inputBuffer[6];

outputFileWriter.Write("SPHR");
outputFileWriter.Write(win1251.GetString(crcOrSmth));
outputFileWriter.Write(win1251.GetString(inputBuffer));
outputFileWriter.Close();
outputFile.Close();

Можем поменять любую понравившуюся строку (0004 на скриншоте), упаковать файл обратно, скопировать его в клиент и посмотреть, что получится.

Измененный, упакованный и распакованный _sys.txt
Измененный, упакованный и распакованный _sys.txt

На этом пока все. До встречи!

Код проекта на GitHub

Приведу его в порядок по мере написания новых частей.

Комментарии (13)


  1. Roland21
    28.07.2022 13:31
    +2

    ох, первая ММОРПГ в жизни, интернет по карточкам, детство и вот это вот все
    и ники знакомые такие


    1. knelse Автор
      28.07.2022 13:36

      Для многих первая, кажется) До Сферы я не знал, что ММО (РПГ) вообще существуют


  1. r2rt2r
    28.07.2022 13:38

    Такое дикое настольджи. Спасибо.


    1. Roman_S
      28.07.2022 15:59

      У меня такое, наверное, чувство от обитания в Ультиме онлайн и Рагнарок онлайн, со всеми этими интернет-картами.


    1. knelse Автор
      28.07.2022 16:07

      Всегда рад :) У меня Сфера вызывает смешанные чувства - она абсолютно сломанная (и не то чтобы идеально продуманная даже без этого), но все равно нравится. Даже не помню, когда в последний раз в нее играл по-нормальному


  1. SirSom
    28.07.2022 13:57

    О, как же долго мы этого ждали


    1. knelse Автор
      28.07.2022 14:11

      Сесть ковыряться в Сфере было тяжело( потом когда оно начало как-то работать стало гораздо веселее

      До координат. Чертовы координаты.


      1. SirSom
        28.07.2022 14:19
        +2

        положение в пространстве, направление взгляда и скорость? или они что-то интереснее придумали?

        Интересно те ребята которые изначально ей занимались, могли бы уже подзабить и помочь такому интересному проекту? :)


        1. knelse Автор
          28.07.2022 15:01
          +2

          Вся физика на сервере, т.е. клиенту отдается текущее положение объекта в пространстве, без скорости / приложенных сил и т.д. Дальше клиент как-то интерполирует перемещение из точки А в точку Б.

          А вот как это самое положение в пространстве лежит в пакете - это прекрасно:

          1. Его минимум 3 варианта в зависимости от отправителя и действия:

            1. Сервер, перемещение объектов

            2. Сервер, все остальное (спавн игрока и предметов, телепорт игрока и т.д.)

            3. Клиент, пинг

          2. Для перемещения объектов сервер кладет в пакет:

            1. 32768 + целую часть значения, если это X или Z

            2. 1200 + целую часть значения, если это Y

            3. Дробную часть каждой координаты (точнее, 4095 - значение для X и Z и 1200 - значение для Y) в другом месте, с точностью примерно до 1/64 этой дробной части. Возможно, тут есть какая-то сильно более простая формула, но я ее пока не понял.

          3. Для всего остального на сервере:

            1. Берем целую часть двоичного логарифма координаты, она же Math.Truncate(log2(x)) (если координата в полуинтервале [0; 1), результат всех операций 58)

            2. Берем кол-во шагов, которое нужно пройти, чтобы от 11 (т.е. базовой координаты 2048) дойти до 2ˆMath.Truncate(log2(x))

            3. Если координата больше 2048, то масштаб (или как его назвать еще) = 69 + steps / 2

            4. Если координата меньше или равна 2048, то масштаб = 69 - (steps + 1) / 2

            5. Число, которое мы будем энкодить = (x / 2ˆ(Math.Truncate(log2(x))) + 1) * 2 ^ 23. Дробную часть отбрасываем.

            6. Дальше нам остается только записать эту чиселку и масштаб, не забыв поменять один из бит на 1, если кол-во шагов из пункта 2 нечетное.

          4. На клиенте похожая история, только масштаб там начинается с 126.

          Я надеюсь, что есть какой-то адекватный алгоритм, делающий эти преобразования проще, но я не очень умный и его не знаю

          Насчет помочь - я всеми руками за, пожалуйста, пишите мне :)

          Координаты перемещения я проверял в стартовом данже, он под землей. Вполне вероятно, что на поверхности или в других диапазонах координат формула окажется другой =/

          Суммарно координаты заняли у меня где-то с месяц по несколько часов в день, наверно.


  1. pewpew
    28.07.2022 15:07

    Весь код проекта написан на C#, для игрового сервера используется Godot.
    Мсье знает толк! Но почему не на GDScript?


    1. knelse Автор
      28.07.2022 15:10

      У меня нет нормального ответа, на самом деле. Я сначала написал почти весь существующий код, пока разбирался, как оно работает, а потом уже подумал, где есть бесплатный navmesh)


  1. xsevenbeta
    29.07.2022 10:38

    Вы не поверите, но есть люди которые вдохновлялись прежде всего Сферой когда начали пилить свою ММО (Reign of Guilds).


    1. knelse Автор
      29.07.2022 13:02

      Имхо, у Сферы были крутой узнаваемый стиль и интересная механика. А возможность в ММО складывать объекты друг на друга и строить лестницы из персонажей, чтобы куда-нибудь залезть, до сих пор иногда взрывает мне мозг :)