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

*Нет связи*: эмулятор Сферы, часть 2

Время на прочтение14 мин
Количество просмотров3.6K
Геймплей Сферы обычно выглядит так
Геймплей Сферы обычно выглядит так

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

Продолжаем собирать эмулятор Сферы на коленке. В прошлой части мы справились с логином и выбором персонажа, на очереди: вход в игру, загрузка инвентаря, пинг. Научимся оставаться в игровом мире дольше чем на 10 секунд после запуска и попробуем не сойти с ума в процессе – пришло время разбираться, как Сфера передает координаты.

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

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

Вход в игру

"Добро пожаловать в Сферу" или другая зеленая надпись по вашему выбору
"Добро пожаловать в Сферу" или другая зеленая надпись по вашему выбору

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

Это первый из встречаемых нами пакетов, длина которого может изменяться. Имя персонажа больше не дополняется до 19 символов, но занимает байт по количеству букв плюс 1 (для хранения длины). Вместо имени клана сервер должен передать 0x00 0x6E, если персонаж не состоит в клане. Для персонажей в клане отправляется имя клана и его длина (точно так же, как для имени персонажа).

С записью текущего (и максимального) хп тоже не все просто.

((CurrentHP & 0b111) << 5) + 0b10011)
(CurrentHP & 0b11111111000) >> 3)
(CurrentHP & 0b11100000000000) >> 11)

Обратите внимание на константу 0b100 (оставшиеся 2 бита, 11, нас сейчас не интересуют). Создатели Сферы, видимо, активно пытались экономить трафик, поэтому в зависимости от константы перед полем клиент прочитает:

  • 0b100 - следующие 14 бит;

  • 0b101 - следующие 22 бита;

  • 0b110 - следующие 30 бит;

  • 0b111 - следующие 62 бита.

Чаще всего так записываются статы персонажа, получение/потеря здоровья и маны, получаемый опыт и прочие - все поля, в которых можно сэкономить (для отправки 0b101 не нужны 32 бита). Что интересно, клиент корректно обработает пакет с 62-битным полем, но отобразить результат правильно не сможет - в интерфейсе все максимум 32-битное.

В прошлый раз я забыл упомянуть, что на экране выбора персонажа уровень передается как абсолютное значение минус 1, без учета сбросов. Соответственно, “великие” уровни начинаются с 60, “величайшие” - с 120, “легендарные” - с 180 (например, “великий” 15 - это 74, а “легендарный” 30 - это 209). Префикс за количество ресетов клиент берет с экрана выбора персонажа, и для входа в игру нужно отправить: (уровень степени - 1) * 100 + (уровень титула - 1). Например, для “легендарный 21 / величайший 20” клиент получит 20*100 + 19 = 20019.

На самом деле, в файлы игры можно руками вписать текстовые строки для 61-75 уровней, которые разработчики в какой-то момент обещали добавить, и тогда они не превратятся в “великий” 1 - “великий” 15. По-нормальному при сбросе уровня персонажи переходят с 60 на “великий” 15, и получить “великие” уровни с 1 до 14 нельзя. Таблицы опыта для них не существует и отображаются они криво, но часть функций работает. Например, цвета полосок здоровья у монстров будут правильными и вещи, требующие 60 уровень, не подсветятся красным. Сделать то же самое с текстовыми строками для 76 и выше уже не выйдет.

Великий Покахабр
Великий Покахабр
Код генерации пакета (осторожно, очень длинный листинг):
var nameEncoded = MainServer.Win1251.GetBytes(Name);
var x = CoordsHelper.EncodeServerCoordinate(X);
var y = CoordsHelper.EncodeServerCoordinate(Y);
var z = CoordsHelper.EncodeServerCoordinate(Z);
var t = CoordsHelper.EncodeServerCoordinate(T);
var nameLen = nameEncoded.Length + 1;
var data = new List<byte>
            {
                0x00,
                0x01,
                0x2C,
                0x01,
                0x00,
                0x00,
                0x04,
                MajorByte(PlayerIndex),
                MinorByte(PlayerIndex),
                0x08,
                0x00,
                (byte)(((nameLen & 0b111) << 5) + 2),
                (byte)(((nameEncoded[0] & 0b111) << 5) + ((nameLen & 0b11111000) >> 3))
            };

for (var i = 1; i < nameEncoded.Length; i++)
{
  data.Add((byte)(((nameEncoded[i] & 0b111) << 5) + ((nameEncoded[i - 1] & 0b11111000) >> 3)));
}

data.Add((byte)((nameEncoded[^1] & 0b11111000) >> 3));

if (string.IsNullOrWhiteSpace(ClanName))
{
  data.Add(0x00);
  data.Add(0x6E);
}
else
{
  var clanNameEncoded = MainServer.Win1251.GetBytes(ClanName);
  var clanNameLength = clanNameEncoded.Length;
  data.Add((byte)((clanNameLength & 0b111) << 5));
  data.Add((byte)(((clanNameEncoded[0] & 0b1111111) << 1) + ((clanNameLength & 0b1000) >> 3)));

  for (var i = 1; i < clanNameLength; i++)
  {
    data.Add((byte)(((clanNameEncoded[i] & 0b1111111) << 1) +
                    ((clanNameEncoded[i - 1] & 0b10000000) >> 7)));
  }

  data.Add((byte)(((0b01100000) + (((byte)ClanRank) << 1) + ((clanNameEncoded[^1] & 0b10000000) >> 7))));
}

data.Add(0x1A);
data.Add(0x98);
data.Add(0x18);
data.Add(0x19);
data.AddRange(x);
data.AddRange(y);
data.AddRange(z);
data.AddRange(t);
data.Add(0x37);
data.Add(0x0D);
data.Add(0x79);
data.Add(0x00);
data.Add(0xF0);
data.Add((byte)(HelmetSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(AmuletSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(ShieldSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(ArmorSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(GlovesSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(BeltSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(LeftBraceletSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(RightBraceletSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(TopLeftRingSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(TopRightRingSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(BottomLeftRingSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(BottomRightRingSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(PantsSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(BootsSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(SpecSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(MapBookSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(RecipeBookSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(MantraBookSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add((byte)(InkpotSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(MoneySlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(TravelbagSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(KeySlot1 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(KeySlot2 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(MissionSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(InventorySlot1 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(InventorySlot2 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(InventorySlot3 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(InventorySlot4 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(InventorySlot5 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(InventorySlot6 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(InventorySlot7 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(InventorySlot8 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(InventorySlot9 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(InventorySlot10 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add((byte)(LeftSpecialSlot1 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(LeftSpecialSlot2 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(LeftSpecialSlot3 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(LeftSpecialSlot4 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(LeftSpecialSlot5 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(LeftSpecialSlot6 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(LeftSpecialSlot7 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(LeftSpecialSlot8 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(LeftSpecialSlot9 is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(AmmoSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add((byte)(SpeedhackMantraSlot is null ? 0x00 : 0x04));
data.Add(0x00);
data.Add(0x04);
data.Add(0x00);
data.Add(0x00);
data.Add(0x00);
data.Add(0x04);
data.Add(0x00);
data.Add(0xF0);

for (var i = 0; i < 150; i++)
{
  data.Add(0x00);
}

data.Add((byte)(((CurrentHP & 0b111) << 5) + 0b10011));
data.Add((byte)((CurrentHP & 0b11111111000) >> 3));
data.Add((byte)(((MaxHP & 0b11) << 6) + (0b100 << 3) + ((CurrentHP & 0b11100000000000) >> 11)));
data.Add((byte)((MaxHP & 0b1111111100) >> 2));
data.Add((byte)((((byte)Karma) << 4) + ((MaxHP & 0b11110000000000) >> 10)));
var toEncode = DegreeLevelMinusOne * 100 + TitleLevelMinusOne;
data.Add((byte)(((toEncode & 0b111111) << 2) + 2));
data.Add((byte)((toEncode & 0b11111111000000) >> 6));

data.Add(0x80);

if (SpecType == SpecTypes.None)
{
  data.Add(0x00);
}
else
{
  data.Add((byte)((1 << 7) + (((byte)SpecType) << 1)));
}

data.Add((byte)(((Money & 0b1111) << 4) + SpecLevelMinusOne));
data.Add((byte)((Money & 0b111111110000) >> 4));
data.Add((byte)((Money & 0b11111111000000000000) >> 12));
data.Add((byte)((Money & 0b1111111100000000000000000000) >> 20));
data.Add((byte)((Money & 0b11110000000000000000000000000000) >> 28));

var arr = data.ToArray();
arr[0] = (byte)arr.Length;

return arr;

Кроме координат, в остальных полях сюрпризов нет, это стандартный bit-packing, кое-где с добавленными константами.

Координаты

Пинг: интересно и познавательно 2 дня, больно недели полторы
Пинг: интересно и познавательно 2 дня, больно недели полторы

В Сфере есть минимум 3 разных способа передачи координат в зависимости от источника (возможно, их больше, но другие я пока не встретил):

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

  2. Сервер: все остальное (загрузка игрока и объектов, телепорт игрока, загрузка инстансов и т.д.).

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

Второй и третий пункт, на самом деле, похожи друг на друга, меняется только формат полей и масштаб (о нем ниже).

Сервер: перемещение объектов

Для перемещения объектов координаты записываются практически в явном виде, но целая и дробная часть в пакете лежат в разных местах. Алгоритм:

Для целых частей:

  1. К значению X и Z прибавим 32768.

  2. К значению Y прибавим 1200 (знать бы, почему).

  Для дробных частей:

  1. Умножим каждое значение на 64 и округлим результат.

  2. Для X и Z отнимем получившиеся значения от 4095.

  3. Для Y отнимем получившееся значение от 1200.

Лайв-сервер, на самом деле, энкодит координаты немного по-другому, но никаких визуальных различий на клиенте я пока не заметил. В нашем случае, целые части изменяются с шагом 1, а дробные всегда лежат в диапазоне [4032; 4095]. На лайв-сервере целая часть в пакете может меняться с шагом в 20-30, а в дробных значениях числа бывают меньше 4032.

Сервер: все остальное

Примечание: возможно, это стандартный энкодинг для геймдева и я просто не в курсе, потыкайте в меня палочкой в комментариях.

Для всего остального на сервере координаты превращаются в логарифмические. Алгоритм такой:

  1. Для интервала (-1; 1) масштаб равен 58.

  2. Возьмем целую часть модуля двоичного логарифма координаты log2_x = Math.Floor (Math.Abs (Math.Log(x, 2))).

  3. Возьмем разницу между ближайшей от координаты степенью двойки снизу и 11. Это наше количество шагов, steps = log2_x - 11. 

  4. Если steps < 0, то масштаб scale = 69 - (steps - 1) / 2

  5. Если steps >= 0, то масштаб scale = 69 + steps / 2

  6. Возьмем положение текущей координаты на логарифмической шкале по степеням двойки logscale_x = Math.Abs(x) / 2 ^ log2_x + 1. Фактически, нас интересует значение из интервала (1; 2), где 1 - это степень двойки ниже числа, а 2 - выше.

  7. Значение для записи в пакет numToEncode = 2^23 * logscale_x, дробную часть отбрасываем

  8. Если исходная координата была отрицательной, старший бит в последнем байте = 1

  9. Если steps нечетное, старший бит в предпоследнем байте = 1

Код записи в пакет
var a_3 = (byte)(((a < 0 ? 1 : 0) << 7) + scale);
var mul = Math.Pow(2, ((int)Math.Log(a_abs, 2)));
var numToEncode = (int)(0b100000000000000000000000 * (a_abs / mul + 1));

var a_2 = (byte)(((numToEncode & 0b111111111111111100000000) >> 16) + (steps % 2 == 1 ? 0b10000000 : 0));
var a_1 = (byte)((numToEncode & 0b1111111100000000) >> 8);
var a_0 = (byte)(numToEncode & 0b11111111);

return new[] { a_0, a_1, a_2, a_3 };

Клиент: пинг

Для клиентского пинга нам на сервере нужно декодировать отправленные клиентом координаты, т.е. построить обратный алгоритм. Нужно еще учитывать, что старший бит в зависимости от количества шагов здесь не меняется, а масштаб начинается с 126 (дежурная шутка про 59 спичек).

Структура байтов в пакета (от старших к младшим, как обычно):

  1. 2 бита “координаты”, 6 бит игнорируем;

  2. 8 бит “координаты”;

  3. 8 бит “координаты”;

  4. 3 бита масштаба, 5 бит “координаты”;

  5. 2 бита игнорируем, 1 бит знака, 5 бит масштаба.

Обратный алгоритм:

  1. Достаем из пакета закодированную “координату”. Точнее, число от 0 до 2^23 - 1, которым записано положение координаты на полуинтервале [0; 1).

  2. Делим его на 2^23 и прибавляем 1.

  3. Умножаем результат на 2^(масштаб - 127) и добавляем знак.

Код
var x_scale = ((a[4] & 0b11111) << 3) + ((a[3] & 0b11100000) >> 5);

if (x_scale == 126)
{
  return 0.0;
}

var baseCoord = Math.Pow(2, x_scale - 127);
var sign = (a[4] & 0b100000) > 0 ? -1 : 1;

return ((1 + ((float)(((a[3] & 0b11111) << 18) + (a[2] << 10) + (a[1] << 2) +
                      ((a[0] & 0b11000000) >> 6))) / 0b100000000000000000000000) * baseCoord) * sign;

Координаты в пинге используются для синхронизации положения между клиентом и сервером (клиент дисконнектится, если различие в отправляемых и получаемых координатах слишком большое), поэтому пока для масштаба 126, он же интервал (-1; 1), безопасно возвращать 0. Для честного сохранения положения игрока в мире мы этот код поправим в следующих частях.

*Нет связи*

Если повезет, перед дисконнектом мы успеем дойти до второго этажа таверны
Если повезет, перед дисконнектом мы успеем дойти до второго этажа таверны

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

Тут клиент не всегда прав. Забегая на пару частей вперед, он честно продолжит обрабатывать некоторые пакеты с сервера. Например, можно добавить несколько монстров прямо перед игроком, нанести игроку урон, поменять статы, подвинуть какие-нибудь объекты и много чего еще.

Гулять по дну озера в Сфере часами - это моя давняя мечта (наверно), поэтому посмотрим, что еще нам нужно отправить на клиент, чтобы оно наконец завелось:

  1. Инвентарь и надетые вещи.

  2. Ответы на клиентские пакеты:

    1. Пинг с координатами игрока, примерно раз в 2 секунды.

    2. Keepalive, примерно раз в 3 секунды. 

  3. Серверные пинги:

    1. Пинг раз, каждые 3 секунды.

    2. Пинг два, каждые 6 секунд.

    3. Пинг три, каждые 15 секунд.

Keepalive клиент использует еще и для расчета текущей задержки соединения.

Инвентарь

Длина пакета инвентаря зависит от наличия в нем предметов и от надетых на персонажа вещей. На этот раз отправляется сама информация о предметах, а не только бит заполнено/пусто, как при загрузке в игру.

В сегменте пакета для каждой вещи:

  1. Если текущий слот пустой, это статичный набор байт и номер слота

  2. Если слот занят, это:

    1. Уникальный ID конкретного айтема.

    2. ID вещи (801 для огненных стрел I, 807 для фаерболлов II и т.д.).

    3. Текущая прочность, если вещь может ломаться, иначе все биты 1.

    4. Тип вещи (меч/топор/арбалет для оружия, ботинки/нагрудник/перчатки/шлем для брони и т.д.).

    5. Ранг вещи.

    6. Суффикс (дистанции/урона/жестокости/…).

    7. Премиум-уровень.

    8. Владелец и привязка к владельцу.

    9. Количество.

Клиент, как обычно, достаточно спокойно относится к кривому формату пакета, можно получить что-нибудь вроде такого:

Сила меча в том, что он бьет насмерть, хотя и не очень часто. На самом деле, это яд
Сила меча в том, что он бьет насмерть, хотя и не очень часто. На самом деле, это яд

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

Содержимое пакетов

Первый

A8002C01000004{cliendId}08406102000A82A0C3E10C000000005010849D0F6700A0252680107D380300142460A720081420CE00640000000D1C0205000080C64640406383ACCCCCAC6C8C6E8E8B0BC0A9EC0E640C2D4C0E24CD0DE42CACAD4C0764C9AD8C0D2E6C2C0C0465C9AD8C0D2E6C2C0C0425A646C565AECC0CE02B2625C54501C08908640C2D4CEE8BAC8CEDCB8C2DEC0C84C70724068429A929890A2406008429A929890A0472002C01000004{cliendId}084063838C2DCC8D6C6E2C0CAE8C8B0BE00E640C2D4C0E24CD0DE42CACAD4C0704789DFF1D5C1D1C0405789DFF1D5C1D1C0425A6068646C5050F0F6F47C5850E8F0E20C88908640C2D4CEE8BAC8CEDCB8C2DEC0C84C70724068429A929890A2406008429A929890A04$72002C01000004{cliendId}084063838C2DCC8D6C6E2C0CAEEC0B4D8E8B0B002D4C0E24CD0DE42CACAD4C0704789DFF1D5C1D1C0405789DFF1D5C1D1C0425A6068646C5050F0F0F40C5850E8F0E20C88908640C2D4CEE8BAC8CEDCB8C2DEC0C84C70724068429A929890A2406008429A929890A0472002C01000004{cliendId}084063838C2DCC8D6C6E2C0CAEEC0B0E8D8B0B002D4C0E24CD0DE42CACAD4C0704789DFF1D5C1D1C0405789DFF1D5C1D1C0425A6068646C5050F0F0F40C5850E8F0E20C88908640C2D4CEE8BAC8CEDCB8C2DEC0C84C70724068429A929890A2406008429A929890A0472002C01000004{cliendId}084063838C2DCC8D6C6E2C0CAEEC4B8E8C8B0B002D4C0E24CD0DE42CACAD4C0704789DFF1D5C1D1C0405789DFF1D5C1D1C0425A6068646C5050F0F0F40C5850E8F0E20C88908640C2D4CEE8BAC8CEDCB8C2DEC0C84C70724068429A929890A2406008429A929890A0472002C01000004{cliendId}08406383AC4D6C8C8B0B200CAEEC4B8E8C8B0B002D4C0E24CD0DE42CACAD4C0704789DFF1D5C1D1C0405789DFF1D5C1D1C0425A6068646C5A54D6C0C40C5850E8F0E20C88908640C2D4CEE8BAC8CEDCB8C2DEC0C84C70724068429A929890A2406008429A929890A0472002C01000004{cliendId}08406383ACED8DAC8C6D8E8B0BE04B8E8C8B0B002D4C0E24CD0DE42CACAD4C0704789DFF1D5C1D1C0405789DFF1D5C1D1C0425A6068646C5A58D8C6D47C5A58D4E6E47C5850E8F0E204CEE8BAC8CEDCB8C2DEC0C84C70724068429A929890A2406008429A929890A0472002C01000004{cliendId}08406383ACED8DAC8C6DEE0B4D8E8B0B808B0B002D4C0E24CD0DE42CACAD4C0704789DFF1D5C1D1C0405789DFF1D5C1D1C0425A6068646C5A58D8C6D47C5A58D4E0E40C5850E8F0E204CEE8BAC8CEDCB8C2DEC0C84C70724068429A929890A2406008429A929890A0472002C01000004{cliendId}08406383ACED8DAC8C6DEE0B0E8D8B0B808B0B002D4C0E24CD0DE42CACAD4C0704789DFF1D5C1D1C0405789DFF1D5C1D1C0425A6068646C5A58D8C6D47C5A58D4E0E40C5850E8F0E204CEE8BAC8CEDCB8C2DEC0C84C70724068429A929890A2406008429A929890A0472002C01000004{cliendId}08406383ACED8DAC8C6DEE4B8E8C8B0B808B0B002D4C0E24CD0DE42CACAD4C0704789DFF1D5C1D1C0405789DFF1D5C1D1C0425A6068646C5A58D8C6D47C5A58D4E0E40C5850E8F0E204CEE8BAC8CEDCB8C2DEC0C84C70724068429A929890A2406008429A929890A0472002C01000004{cliendId}084063830C2E4C2EAC6D8E8B0B808B0B808B0B002D4C0E24CD0DE42CACAD4C0704789DFF1D5C1D1C0405789DFF1D5C1D1C0425A6068646C565CCEC6C47C5A54D8C0C40C5850E8F0E204CEE8BAC8CEDCB8C2DEC0C84C70724068429A929890A2406008429A929890A0472002C01000004{cliendId}084063830C2E4C2EAC6D8E8BAB2D4CAF6C8E8B0B204C0E24CD0DE42CACAD4C0764C9AD8C0D2E6C2C0C0465C9AD8C0D2E6C2C0C0425A646C5A54D8C0C40C5A54D8C0C40C5850E8F0E204CEE8BAC8CEDCB8C2DEC0C84C70724068429A929890A

Второй

2406008429A929890A0472002C01000004{cliendId}084063830C2E4C2EAC6D8E8B4BEEEDAD6D8E8B0B204C0E24CD0DE42CACAD4C0704789DFF1D5C1D1C0405789DFF1D5C1D1C0425A6068646C5A54D8C0C40C5A54D8C0C40C5850E8F0E204CEE8BAC8CEDCB8C2DEC0C84C70724068429A929890A2406008429A929890A0472002C01000004{cliendId}084063830C2E4C2EAC6D8E8B6BAEAC8C6C8E8B0B204C0E24CD0DE42CACAD4C0704789DFF1D5C1D1C0405789DFF1D5C1D1C0425A6068646C565CCEC0C40C5A54D8C0C40C5850E8F0E204CEE8BAC8CEDCB8C2DEC0C84C70724068429A929890A2406008429A929890A0477002C01000004{cliendId}084063830CAF0E8E2C8CAE8C8B0BA08C6C8E8B0B204C0E24CD0DE42CACAD4C0704789DFF1D5C1D1C0405789DFF1D5C1D1C0425A60686660A0EAD4CAECCA50CAF6C670A0EAD4CAE6C882DADCC8DCEA50CAF0CE00C84C70724068429A929890A2406008429A929890AA4B121200000

Пинги

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

Основной

26 00 C2 43 3A 07 2C 01 00 3B 05 55 ED E8 20 50 F3 E9 0D 01 60 10 00 D0 09 11 00 F4 63 11 00 30 1A 11 00 00 80 0E

Часть содержимого мы уже видели в прошлой статье:

26 - длина

C2 42 3A 07 - байты синхронизации

2C 01 - маркер корректности

Байты с 9 до 21 (3B .. 10) понадобятся нам для ответного пакета. Начиная с 17 байта и дальше (E9 .. 0E) лежат значения X, Y и Z координат и угла поворота. Декодирование их описано чуть выше. 

Ответный пакет:

{ clientByte_0, clientByte_1, clientByte_2, clientByte_3, clientByte_4, topByteToXor, MinorByte(pingCounter), MajorByte(pingCounter), clientByte_8, clientByte_9, clientByte_10, clientByte_11, 0x00}  

clientByte_N - это соответствующий по индексу байт клиентского пинга без изменений

topByteToXor - это пятый байт пинга, каждый второй раз делается XOR с 0b100000000

pingCounter - это байты синхронизации времени сервера и клиента

Клиент считает время в игровых секундах (в 12 раз быстрее), а сервер - в реальных. Поэтому алгоритм такой:

  1. Возьмем значение из 6 и 7 байта пинга.

  2. Отнимем от него 0xE001.

  3. Поделим на 12, прибавим к 0xE001.

Минимальное значение маркера времени - 0xE001, и после переполнения отсчет снова должен начаться с 0xE001, иначе клиент через несколько секунд показывает “нет связи”. Мне повезло, я заметил правильное начало отсчета всего на третий день.

Остальные

В остальных пакетах меняется только индекс клиента, другие поля статичны.

  1. Эхо, отправляется в ответ на 08 00

0x14, 0x00, 0x2C, 0x01, 0x00, 0x00, 0x04, MajorByte(playerIndex),
MinorByte(playerIndex), 0x08, 0xC0, 0x42, 0x60, 0xFE, 0xD3, 0x90, 0x10, 0xB0, 0x17, 0x00
  1. Раз в 3 секунды:

0x04, 0x00, 0xF4, 0x01
  1. Раз в 6 секунд:

0x13, 0x00, 0x2C, 0x01, 0x00, 0x00, 0x04, MajorByte(playerIndex),
MinorByte(playerIndex), 0x08, 0xC0, 0x42, 0xA0, 0xFF, 0xD3, 0x90, 0x08, 0xB0, 0x07
  1. Раз в 15 секунд:

0x10, 0x00, 0x2C, 0x01, 0x00, 0x00, 0x04, MajorByte(playerIndex),
MinorByte(playerIndex), 0x08, 0x40, 0x81, 0x93, 0xEE, 0xE4, 0x08

Если пинги отправляются правильно, мы можем гулять по игровому миру сколько захочется, но в нем ничего не происходит (для Early Access в Steam как раз подойдет). В следующей части научимся добавлять объекты, собирать предметы с земли, убивать монстров и получать за них награду. До встречи!

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

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

Публикации

Истории

Работа

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