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

Мало картинок, много лута: эмулятор Сферы, часть 4

Время на прочтение11 мин
Количество просмотров3.2K
Это единственная картинка во всей статье, зато лута на ней много
Это единственная картинка во всей статье, зато лута на ней много

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

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

Мешочек

В первую очередь, клиент должен узнать о том, что мешочек с лутом выпал. Для этого сервер отправляет специальный пакет размером 29 (0x1D) байт с ID объекта и серверными координатами:

  {
      0x1D, 0x00, 0x2C, 0x01, 0x00, 0x00, 0x00, MinorByte(localId), MajorByte(localId), 0x5C, 0x86, (byte) x_1,
      (byte) x_2, (byte) x_3, (byte) x_4, (byte) y_1, (byte) y_2, (byte) y_3, (byte) y_4, (byte) z_1,
      (byte) z_2, (byte) z_3, (byte) z_4, (byte) z_5, 0x20, 0x91, 0x45, 0x06, 0x00
  };

При открытии мешочка клиент присылает пакет длиной 26 (0x1A) байт, который мы уже видели при поднятии предмета с земли. Скорее всего, похожие пакеты используются для действий с игровым миром. На этот раз, правда, отличается “тип пакета” (0x5C 0x46 0xE1), и ID целевого объекта лежит в 11 и 12 байтах (0x41 0x00):

1A 00 A8 42 70 27 2C 01 00 AC D3 41 00 5C 46 E1 06 9E DE 00 0A 3A F0 F4 06 00

В ответ сервер отдает информацию о занятых слотах в мешочке: ID предмета и его вес для каждого слота. Примеры пакетов для 1-4 занятых слотов под спойлером.

Примеры
case 1:
    itemList = new byte[]
    {
        0x19, 0x00, 0x2C, 0x01, 0x00, 0x00, 0x00, MinorByte(localId), MajorByte(localId), 0x5C, 
        0x46, 0x61, 0x02, 0x00, 0x0A, 0x82, 0x00, item0_1, item0_2, item0_3, 0x70, 0x0D, 0x00, 0x00, 0x00 
    };

    break;
case 2:
    itemList = new byte[]
    {
        0x23, 0x00, 0x2C, 0x01, 0x00, 0x00, 0x00, MinorByte(localId), MajorByte(localId), 0x5C, 
        0x46, 0x61, 0x02, 0x00, 0x0A, 0x82, 0x00, item0_1, item0_2, item0_3, /*weight*/ 0xC0, 0x00, 0x00, 
        0x00, 0x50, 0x10, 0x84, item1_1, item1_2, item1_3, /*weight*/ 0x00, 0x4B, 0x00, 0x00, 0x00
    };

    break;
case 3:
    itemList = new byte[]
    {
        0x2E, 0x00, 0x2C, 0x01, 0x00, 0x00, 0x00, MinorByte(localId), MajorByte(localId), 0x5C, 
        0x46, 0x61, 0x02, 0x00, 0x0A, 0x82, 0x00, item0_1, item0_2, item0_3, 0x30, 0x00, 0x00, 0x00, 0x50, 
        0x10, 0x84, item1_1, item1_2, item1_3, 0x00, 0x08, 0x00, 0x00, 0x80, 0x82, 0x20, 0x08, item2_1, 
        item2_2, item2_3, 0x2C, 0x00, 0x00, 0x00, 0x00
    };

    break;
case 4:
    itemList = new byte[]
    {
        0x38, 0x00, 0x2C, 0x01, 0x00, 0x00, 0x00, MinorByte(localId), MajorByte(localId), 0x5C, 
        0x46, 0x61, 0x02, 0x00, 0x0A, 0x82, 0x00, item0_1, item0_2, item0_3, 0x30, 0x00, 0x00, 0x00, 0x50, 
        0x10, 0x84, item1_1, item1_2, item1_3, 0x00, 0x08, 0x00, 0x00, 0x80, 0x82, 0x20, 0x08, item2_1, 
        item2_2, item2_3, 0x2C, 0x00, 0x00, 0x00, 0x14, 0x04, 0x61, item3_1, item3_2, item3_3, 0x80, 0x19, 
        0x00, 0x00, 0x00
    };

Пакеты похожи на вид, и подставляя в остальные ячейки заведомо некорректные ID, мы можем всегда отправлять последний (4 айтема) вне зависимости от количества предметов.

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

Предметы

Воспользуемся даром предвидения и заранее решим, что одни и те же предметы в пакете всегда энкодятся одинаково. Это (совсем) не так, но рассудок пока дороже. Несколько процентов некорректных предметов, в лучшем случае, просто не отобразятся, а в худшем — приведут к вылету клиента. Разберемся с ними позже.

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

  1. Мантры

  2. Оружие, броня

  3. Порошки, алхимические ингредиенты, части монстров

  4. Кольца

  5. Уникальные (хворост, еда, жетоны, мешки, свитки, формулы, книги и т.д.)

Примеры пакетов:

Header        sync    id      type    "static"                            game_id   suffix  bag_id    count,etc
Мантра “Три звездочки"
28002C0100    029E    AE87    A48F    0F80842E090000000000000000409145    E65912    1560    A0F510    A0C0020100

Шлем IX на 90 выносливости
2B002C0100    FC0A    06FB    D48B    0F80842E090000000000000000409145    A61613    1560    C05D1F    A0900500FFFFFFFF

Усики сколопендры 
30002C0100    08C5    87C0    148B    0F80842E090000000000000000409145    A62310    1560    C01018    A0900500FFFFFFFF0516080000

Кольцо воздуха на 34 титул
3C002C0100    E035    A451    E08B    0F80842E090000000000000000409145    26FCA3    4701    0644A3    000A5900F0FFFFFF5F7807B5BB2FB2B4B036B934B7B3    981A    00

Хлебная лепешка
2E002C0100    A485    659E    348A    0F80842E090000000000000000409145    068002    0C9C    C00314    B200E0FFFFFFBFC0020100

Малая книга мантр
2D002C0100    763B    4571    6486    0FF0502E09308023198095F7F85F9145    068002    0CC0    D60114    2620A0900500FFFFFFFF

Формула
A8002C0100    0A03    CCF1    908C    0F80842E090000000000000000409145    068002    0C80    A90314    582000A0F00D046919000005FFFFFFFF000200854F00708B0000B08B0000283C43F4000040EB00000000000040E107281400

Если присмотреться, некоторые сегменты пакетов похожи друг на друга. В общем случае, в пакете будут:

  1. ID объекта (должен совпадать каким-то из ранее отправленных занятых слотов)

  2. Тип предмета (например, E08B для обычных колец). Хранится со сдвигом, поэтому правильное значение для колец — 760.

  3. Статическая последовательность вида 0F80842E090000000000000000409145 

  4. ID предмета в игровой базе данных (файлы params/group_*.cfg)

  5. Суффикс (например, 4701 для кольца воздуха). Для неуникальных предметов без суффикса — всегда 1560

  6. ID контейнера (например, ID мешочка, в котором лежит предмет)

Дополнительные возможные поля:

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

  2. Флаг премиум-уровня (050F08). Указывается только для премиумных вещей.

  3. Флаг созданной игроком вещи. Отличается в зависимости от уровня специальности.

Для уникальных айтемов записи в игровой базе данных нет. Вместо этого каждому отдельному предмету соответствует свой тип (например, 551 для кольца с рубином и 552 для кольца с алмазом) и чаще всего — свой пакет. Список типов, которые мне удалось найти, под спойлером.

Типы
  Token = 8,
  Mutator = 30,
  SeedCastle = 40,
  XpPillDegree = 47,
  TokenMultiuse = 66,
  TradeLicense = 68,
  ScrollLegend = 90,
  ScrollRecipe = 91,
  TokenIsland = 104,
  TokenIslandGuest = 105,
  Bead = 236,
  BackpackLarge = 400,
  BackpackSmall = 401,
  Sack = 405,
  Chest = 406,
  MantraBookSmall = 409,
  RecipeBook = 410,
  MantraBookLarge = 411,
  MantraBookGreat = 412,
  MapBook = 413,
  KeyBarn = 418,
  PowderFinale = 451,
  PowderTarget = 453,
  PowderAmilus = 454,
  PowderAoE = 455,
  ElixirCastle = 471,
  ElixirTrap = 472,
  WeaponSword = 500,
  WeaponAxe = 501,
  WeaponCrossbow = 502,
  Arrows = 503,
  RingDiamond = 551,
  RingRuby = 552,
  Ruby = 553,
  RingGold = 555,
  AlchemyMineral = 600,
  AlchemyPlant = 601,
  AlchemyMetal = 602,
  FoodApple = 650,
  FoodPear = 651,
  FoodMeat = 652,
  FoodBread = 653,
  FoodFish = 655,
  AlchemyBrushwood = 700,
  Key = 701,
  Map = 703,
  Inkpot = 704,
  Firecracker = 705,
  Ear = 706,
  EarString = 708,
  MonsterPart = 709,
  Firework = 712,
  ArmorChest = 750,
  ArmorAmulet = 751,
  ArmorBoots = 752,
  ArmorGloves = 754,
  ArmorBelt = 755,
  ArmorShield = 756,
  ArmorHelmet = 757,
  ArmorPants = 758,
  ArmorBracelet = 759,
  Ring = 760,
  ArmorRobe = 761,
  RingGolem = 762,
  AlchemyPot = 800,
  Blueprint = 804,
  QuestArmorChest = 949,
  QuestArmorAmulet = 950, // unused?
  QuestArmorBoots = 952,
  QuestArmorGloves = 953,
  QuestArmorBelt = 954,
  QuestArmorShield = 955,
  QuestArmorHelmet = 956,
  QuestArmorPants = 957,
  QuestArmorBracelet = 958, // unused?
  QuestArmorRing = 959, // unused?
  QuestArmorRobe = 960,
  QuestWeaponSword = 961,
  QuestWeaponAxe = 962,
  QuestWeaponCrossbow = 963,
  SpecialGuild = 976, // sometimes different
  SpecialAbility = 977, // same type for specialization itself
  ArmorHelmetPremium = 990,
  MantraWhite = 1000,
  MantraBlack = 1001,

В таком зоопарке вариантов разбираться не очень хочется, да и правильно разделить поля в каждом пакете — развлечение минимум на пару месяцев (одни и те же данные в них могут лежать в разных местах с разным сдвигом, я пытался). К счастью, мы можем взять готовые данные с лайв-сервера, заменить в них минимально необходимый набор полей и отдать клиенту, правда?

Если вы ответили на этот вопрос положительно, вы, к сожалению, не читали моих предыдущих статей, потому что Сфера любит делать больно по-другому.

Ладно, я немного соврал — готовых данных с лайв-сервера хватит для ситуаций, когда клиент получает ровно один предмет. В следующем разделе мы посмотрим, как собрать комплект пакетов на все случаи жизни, а пока под спойлером пример энкодинга. Стандартные стримы в C# не поддерживают сдвиги в нецелое количество байт, поэтому здесь и далее я пользуюсь своим форком BitStream с несколькими дополнительными helper-методами.

Энкодинг
  stream.WriteUInt16(Id);
  stream.WriteBits(_skip1);
  stream.WriteUInt16(Type, 10);
  stream.WriteBits(_skip2);
  stream.WriteBytes(X, 4, true);
  stream.WriteBytes(Y, 4, true);
  stream.WriteBytes(Z, 4, true);
  stream.WriteBytes(T, 4, true);
  stream.WriteUInt16(GameId, 14);
  
  if (FourBitShiftedSuffix)
  {
      stream.WriteUInt16(SuffixMod, 12);
  }
  else
  {
      stream.WriteByte((byte) SuffixMod);
  }
  
  stream.WriteBits(_skip3);
  stream.WriteUInt16(BagId);
  stream.WriteBits(_skip4);

  if (ObjectType is ObjectType.Arrows or ObjectType.Bead or ObjectType.Ruby or ObjectType.Token
      or ObjectType.AlchemyBrushwood or ObjectType.AlchemyMetal or ObjectType.AlchemyMineral
      or ObjectType.AlchemyPlant or ObjectType.ElixirCastle or ObjectType.ElixirTrap or ObjectType.FoodApple
      or ObjectType.FoodBread or ObjectType.FoodFish or ObjectType.FoodMeat or ObjectType.FoodPear
      or ObjectType.MantraBlack or ObjectType.MantraWhite or ObjectType.MonsterPart or ObjectType.PowderAmilus
      or ObjectType.PowderFinale or ObjectType.PowderTarget or ObjectType.RingDiamond or ObjectType.RingRuby
      or ObjectType.SeedCastle or ObjectType.TokenIsland or ObjectType.PowderAoE)
  {
      stream.WriteUInt16(Count);
  }

Разделители и битовые сдвиги

Айтемы в пакет записываются линейно ([предмет1] [разделитель] [предмет2] [...]). Теоретически, мы могли бы разделить его на последовательности известной длины, и каждую из них затем разбирать как отдельный предмет. Сработает такой подход не всегда:

  1. Предмет может занимать нецелое количество байт, итоговый сдвиг в стриме зависит от типа предмета

  2. Перед некоторыми суффиксами нужно дописать дополнительные 4 бита, иначе клиент их не распознает

  3. Маркер созданной игроком вещи отличается у каждого ранга каждой специальности

  4. Для премиальных вещей добавляются фиксированные 3 байта и маркер (разные последовательности для 1 и 2 премиум-уровней)

  5. Некоторым предметам просто нужна более длинная последовательность

Самая большая проблема — в последнем пункте. Возьмем, например, 5 разных шлемов без суффиксов, которые отличаются только ID в игровой базе данных (по этому ID клиент определяет характеристики, требования и т.д.). Запись четырех из них займет условные 30 байт, а запись пятого — условные 35. При этом пакеты для первых четырех шлемов будут одинаковыми с точностью для ID, то есть мы можем хранить один пакет и перед отправкой подменять в нем ID на нужный. Для пятого шлема такая операция приведет к вылету клиента. Никакой закономерности у меня найти не получилось.

Вернемся к списку пакетов с айтемами (выше) еще раз. Последовательность 0F80842E090000000000000000409145 в середине пакета всегда начинается с 11 байта и занимает 16 байт, но по малой книге мантр (0FF0502E09308023198095F7F85F9145) видно, что ее содержимое может изменяться. Посмотрев еще на пару десятков тысяч пакетов, мы заметим, что первый байт всегда равен 0x0F, а последние 4 байта должны удовлетворять набору условий:

  1. Байт 0 — младший разряд равен 8, 9 или 0

  2. Байт 1 — старший разряд равен 4 или 5

  3. Байт 2 — нечетное число

  4. Байт 3 — равен 0x44 или 0x45

Останется сделать из этих условий фильтр (подойдут любые пакеты, в которых есть искомая последовательность байт) и разделить каждый пакет на отдельные предметы. Сдвиг в стриме заранее не известен, поэтому искать придется побитово. Пример кода: 

try
{
    while (containerStream.ValidPosition)
    {
        var test = containerStream.ReadBytes(4, true);

        if (!containerStream.ValidPosition) break;

        containerStream.SeekBack(32);

        if (IsObjectPacket(test))
        {
            var pos = (containerStream.Offset - 16) * 8 + containerStream.Bit;
            var currentOffset = containerStream.Offset;
            var currentBit = containerStream.Bit;
            
            containerStream.Seek(currentOffset - 14, currentBit);
            containerStream.ReadBits(2);
            var typeCheck = containerStream.ReadUInt16(10);

            if (Enum.IsDefined(typeof(ObjectType), typeCheck))
            {
                offsets.Add(pos);
            }
            else
            {
                Console.WriteLine($"Unknown: {typeCheck} at {currentOffset} {currentBit}");
            }
            
            containerStream.Seek(currentOffset, currentBit);
        }

        containerStream.ReadBit();
    }
}
catch (IOException)
{
    
}
finally
{
    if (offsets.Count > 0)
    {
        containerStream.Seek(offsets[0] / 8, (int) (offsets[0] % 8));
    }
}

offsets.Add(containerStream.Length * 8);

Код точно можно оптимизировать, например двигаться вперед по стриму на минимальный размер пакета каждый раз, когда последовательность с айтемом найдена. Протокол, правда, не гарантирует, что между частями одного длинного пакета не попадет пинг или что-нибудь еще (для инвентаря, длина которого гарантированно больше 1400 байт, возможна структура [инвентарь1][пинг][инвентарь2]). Вместо обработки исключений тоже можно взять что-нибудь поприличнее.

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

  [BsonId]
  public int DbId { get; set; }
  public ushort Id { get; set; }
  public Bit[] _skip1 { get; set; }
  public ushort Type { get; set; }
  public Bit[] _skip2 { get; set; }
  public byte[] X { get; set; }
  public byte[] Y { get; set; }
  public byte[] Z { get; set; }
  public byte[] T { get; set; }
  public ushort GameId { get; set; }
  public ushort SuffixMod { get; set; }
  public Bit[] _skip3 { get; set; }
  public ushort BagId { get; set; }
  public Bit[] _skip4 { get; set; }
  public ushort Count { get; set; }
  public bool IsPremium { get; set; }
  public Bit[] _premiumSkip { get; set; }
  public Bit[] _strangeSkip { get; set; }
  public string FriendlyName { get; set; }
  public SphGameObject? GameObject { get; set; }
  public byte[] Packet { get; set; }
  public long BitsRead { get; set; }
  public bool FourBitShiftedSuffix { get; set; }
  public bool IsStrangeSuffix { get; set; }
  public ObjectPacketEncodingGroup EncodingGroup { get; set; }
  public ObjectType ObjectType { get; set; }

БД подойдет любая, в моем случае — LiteDB. Для ранних бета-версий Godot (4.0 beta 2 и около) нужно было менять целевую версию .NET на 4.7/4.8, чтобы проект собрался, с новыми такой проблемы нет.

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

Теперь остается только достать из базы нужные записи, подменить в них набор полей, записать в пакет подряд и отдать клиенту. Если нигде не ошибиться, клиент должен правильно распознать предметы.

Пример кода

ID — это номер конкретного пакета из собранной базы, в вашем случае они будут другими.

var weaponArmorNotShiftedId = 243;
var weaponArmorShiftedId = 153;
var ringNotShiftedId = 666;
var ringShiftedId = 637;
var mantraId = 354;
var alchemyId = 374;
var powderId = 147;
var foodAppleId = 2161;
// var keyId = 296;
// var mantraBookId = 317;
// var tokenId = 330;
// var diamondRingId = 569;

ObjectPacket result;
var objectType = item.ObjectType.GetPacketObjectType();
var suffixMod = item.Suffix == ItemSuffix.None
    ? (ushort)81
    : (ushort)GameObjectDataHelper.ObjectTypeToSuffixLocaleMap[item.ObjectType][item.Suffix].value;

var dbId = -1;
if (GameObjectDataHelper.WeaponsAndArmor.Contains(item.ObjectType))
{
    dbId = suffixMod > 1000 ? weaponArmorShiftedId : weaponArmorNotShiftedId;
}

else if (GameObjectDataHelper.Mantras.Contains(item.ObjectType))
{
    dbId = mantraId;
}

else if (GameObjectDataHelper.Powders.Contains(item.ObjectType))
{
    dbId = powderId;
}

else if (GameObjectDataHelper.AlchemyMaterials.Contains(item.ObjectType))
{
    dbId = alchemyId;
}

else if (item.ObjectType is GameObjectType.Ring)
{
    dbId = suffixMod > 1000 ? ringShiftedId : ringNotShiftedId;
}

else if (item.ObjectType is GameObjectType.FoodApple)
{
    dbId = foodAppleId;
}

if (dbId == -1)
{
    Console.WriteLine(
        $"NOT FOUND: Type: {Enum.GetName(item.ObjectType)} Suffix: {suffixMod} {Enum.GetName(item.Suffix)}");
    dbId = 4;
}

result = MainServer.LiveServerObjectPacketCollection.FindOne(x => x.DbId == dbId);
var client = MainServer.ActiveClients!.GetValueOrDefault(clientId, null);
if (client is null)
{
    return null;
}

result.Id = client.GetLocalObjectId(item.Id);
if (objectType is not ObjectType.FoodApple)
{
    result.GameId = (ushort)item.GameId;
    result.SuffixMod = suffixMod;
}

var bagLocalId = Client.GetLocalObjectId(clientId, bagId);
result.BagId = bagLocalId;
result.Count = (ushort)item.ItemCount;

result.GameObject = MainServer.GameObjectCollection.FindById(item.GameObjectDbId);
if (objectType is not ObjectType.FoodApple)
{
    result.FriendlyName =
        MainServer.GameObjectCollection.FindById((int)result.GameId)!.Localisation[Locale.Russian];
    var type = result.GameObject.ObjectType;
    result.Type = (ushort)objectType;
    if (GameObjectDataHelper.ObjectTypeToSuffixLocaleMap.ContainsKey(type))
    {
        result.GameObject.Suffix = GameObjectDataHelper.ObjectTypeToSuffixLocaleMap[type]
            .GetSuffixById(result.SuffixMod);
    }
}

return result;

На этом все, в следующий раз посмотрим на торговлю с вендорами, примерим свежесобранные вещи и выйдем, наконец, во внешний мир.

До встречи!

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

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

Публикации

Истории

Работа

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