Как стать автором
Обновить

Ужасный %s, известный своими бесчинствами: эмулятор Сферы, часть 1

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

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

Прошло лет 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

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

Теги:
Хабы:
Всего голосов 12: ↑12 и ↓0+12
Комментарии13

Публикации

Истории

Работа

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань