Steam Files. Часть 2 — BLOB, CDR, VDF, PAK, VPK

Steam Logo
После значительной задержки публикую продолжение цикла своих статей.
Для ознакомления:
В данной статье я затрону оставшиеся форматы файлов:

  • BLOB — устаревший формат данных, служащий контейнером для двоичных данных. Содержал в себе базовые параметры (IP-адреса серверов, CRD-запись и еще много чего);
  • CDR (Content Description Record) — бинарный файл, содержащий данные о приложениях и их файлах кеша. На данный момент не используется;
  • VDF — бинарный/текстовый файл, содержащий множество данных и имеющий структуру, зависящую от конкретного применения. Разработан как замена BLOB и CDR;
  • PAK — ранее использовался в Half-Life 1, великое наследие Quake 1, уже не используется;
  • VPK — новый формат игровых архивов внутри самих игр, активно используется на данный момент. Подробное описание файла имеется на официальном ресурсе. В статье описана только первая версия формата.


Статья представлена только для ознакомления, поскольку актуальной информации здесь относительно мало, а примеров алгоритмов почти нет — всё можно просмотреть в упомянутом ранее репозитории.

BLOB (Binary Large OBject)


В предыдущих версиях клиента Steam использовался в единственном экземпляре — ClienRegistry.blob.
Имеет четкую структуру в виде дерева и читается рекурсивно до исчерпания дочерних элементов. Отдельных заголовков не имеет — сразу идет корневой узел, имеющий минимум 1 потомка. Формат несколько нелинейный, о чем укажу далее.Заголовок узла
Каждый узел имеет 2 заголовка — заголовок самого узла и заголовок данных узла.
Формат заголовка узла:

struct TBLOBNodeHeader
{
        UINT16 Magic;
        UINT32 Size;
        UINT32 SlackSize;
};


Magic — поле, описывающее тип узла. Возможные значения:

  • 0x5001 — простой узел с дочерними узлами;
  • 0x4301 — сжатый узел, необходимо пройтись по данным в нем deflate'ом и считать заголовки полученных данных заново (вот она, нелинейность!);
  • прочие значения (обычно 0x0000) — именованный узел, содержащий потомков.


Size — собственно размер данных, хранящихся в узле (не включает в себя заголовки);
SlackSize — размер блока данных, записанного для выравнивания в файле.Заголовок сжатых данных
Если узел был сжат, то после заголовка узла следует заголовок сжатых данных:

struct TBLOBCompressedDataHeader
{
        UINT32 UncompressedSize;
        UINT32 unknown1;
        UINT16 unknown2;
};


UncompressedSize — размер «сырых» данных, под которые необходимо будет выделить память;
unknown1, unknown2 — назначение неизвестно, всегда равно 0x00000001, на парсинг не влияют.
Как и писалось выше, для данных, полученных после вызова uncompress из ZLib'а, следует повторно считать заголовок узла.Разбор данных
После чтения заголовка узла и, по необходимости, распаковки его содержимого, наступает самая «веселая» часть — чтение содержимого узла. Алгоритм был максимально оптимизирован, из-за чего разобраться в нем по прошествии такого промежутка времени оказалось не так-то и просто.
Разбор данных зависит от поля TBLOBNodeHeader.Magic — если оно равно 0x5001, то сразу читаем узлы-потомки.
В противном случае читаем заголовок TBLOBDataHeader

struct TBLOBDataHeader
{
        UINT16 NameLen;
        UINT32 DataLen;
};


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

  • Если 0x5001 или 0x4301 — читаем новый узел;
  • В противном случае — сохраняем как просто данные.


Разбор данных
C++
void CBLOBNode::DeserializeFromMem(char *mem)
{
        TBLOBNodeHeader *NodeHeader = (TBLOBNodeHeader*)mem;
        TBLOBDataHeader *DataHeader = (TBLOBDataHeader*)mem;
        char *data = NULL;

        if (NodeHeader->Magic == NODE_COMPRESSED_MAGIC)
        {
                mem += sizeof(TBLOBNodeHeader);
                TBLOBCompressedDataHeader *CompressedHeader = (TBLOBCompressedDataHeader*)mem;
                mem += sizeof(TBLOBCompressedDataHeader);
                UINT32 compSize = NodeHeader->Size,
                        uncompSize = CompressedHeader->UncompressedSize;
                data = new char[uncompSize];
                if (uncompress((Bytef*)data, (uLongf*)&uncompSize, (Bytef*)mem, compSize) != Z_OK)
                        return;
                mem = data;
                NodeHeader = (TBLOBNodeHeader*)mem;
                DataHeader = (TBLOBDataHeader*)mem;
        }

        if (NodeHeader->Magic == NODE_MAGIC)
        {
                fIsData = false;
                fDataSize = NodeHeader->Size;
                fSlackSize = NodeHeader->SlackSize;
                fChildrensCount = GetChildrensCount(mem);
                fChildrens = new CBLOBNode*[fChildrensCount];
                mem += sizeof(TBLOBNodeHeader);
                for (UINT i=0 ; i<fChildrensCount ; i++)
                {
                        fChildrens[i] = new CBLOBNode();
                        fChildrens[i]->DeserializeFromMem(mem);
                        NodeHeader = (TBLOBNodeHeader*)mem;
                        DataHeader = (TBLOBDataHeader*)mem;
                        if ((NodeHeader->Magic == NODE_MAGIC) || (NodeHeader->Magic == NODE_COMPRESSED_MAGIC))
                                mem += NodeHeader->Size + NodeHeader->SlackSize;
                        else
                                mem += sizeof(TBLOBDataHeader) + DataHeader->DataLen + DataHeader->NameLen;
                }
        }
        else
        {
                fIsData = true;
                fNameLen = DataHeader->NameLen;
                fDataSize = DataHeader->DataLen;
                mem += sizeof(TBLOBDataHeader);
                fName = new char[fNameLen+1];
                memcpy(fName, mem, fNameLen);
                fName[fNameLen] = '\x00';
                mem += fNameLen;
                UINT16 node;
                memcpy(&node, mem, 2);
                if ((node == NODE_MAGIC) || (node == NODE_COMPRESSED_MAGIC))
                {
                        DeserializeFromMem(mem);
                        fData = NULL;
                }
                else
                {
                        fData = new char[fDataSize];
                        memcpy(fData, mem, fDataSize);
                }
        }

        if (data != NULL)
                delete data;
}

Delphi
procedure TBLOBNode.DeserializeFromMem(Mem: pByte);
var
  NodeHeader: pBLOBNodeHeader;
  DataHeader: pBLOBDataHeader;
  CompressedHeader: TBLOBCompressedDataHeader;
  compSize, uncompSize: uint32;
  Data: Pointer;
  ChildrensCount, i: integer;
  //str: TStream;
begin
  NodeHeader:=pBLOBNodeHeader(Mem);
  DataHeader:=pBLOBDataHeader(Mem);
  Data:=nil;

  if (NodeHeader^.Magic=NODE_COMPRESSED_MAGIC) then
  begin
    inc(Mem, sizeof(TBLOBNodeHeader));
    Move(Mem^, CompressedHeader, sizeof(TBLOBCompressedDataHeader));
    inc(Mem, sizeof(TBLOBCompressedDataHeader));
    compSize:=NodeHeader^.Size-sizeof(TBLOBNodeHeader)-sizeof(TBLOBCompressedDataHeader);
    uncompSize:=CompressedHeader.UncompressedSize;
    GetMem(Data, uncompSize);
    uncompress(Data, uncompSize, Mem, compSize);
    Mem:=Data;
    NodeHeader:=pBLOBNodeHeader(Mem);
    DataHeader:=pBLOBDataHeader(Mem);
      {
    Str:=TStream.CreateWriteFileStream('.\dr.unc');
    str.Write(Mem^, uncompSize);
    str.Free;  }
  end;

  if (NodeHeader^.Magic=NODE_MAGIC) then
  begin
    fIsData:=false;
    fDataLen:=NodeHeader^.Size;
    fSlackLen:=NodeHeader^.StackSize;
    {if fSlackLen<>0 then
      Writeln(fSlackLen);}
    ChildrensCount:=GetChildrensCount(Mem);
    SetLength(fChildrens, ChildrensCount);
    inc(Mem, sizeof(TBLOBNodeHeader));
    for i:=0 to ChildrensCount-1 do
    begin
      fChildrens[i]:=TBLOBNode.Create();
      fChildrens[i].DeserializeFromMem(Mem);
      NodeHeader:=pBLOBNodeHeader(Mem);
      DataHeader:=pBLOBDataHeader(Mem);
      if (NodeHeader^.Magic=NODE_MAGIC) or (NodeHeader^.Magic=NODE_COMPRESSED_MAGIC) then
        inc(Mem, NodeHeader^.Size+NodeHeader^.StackSize)
          else inc(Mem, sizeof(TBLOBDataHeader)+DataHeader^.NameLen+DataHeader^.DataLen);
    end;
  end
    else
  begin
    fIsData:=true;
    fNameLen:=DataHeader^.NameLen;
    fDataLen:=DataHeader^.DataLen;
    inc(Mem, sizeof(TBLOBDataHeader));
    SetLength(fName, fNameLen);
    Move(Mem^, fName[1], fNameLen);
    inc(Mem, fNameLen);
    {if (fDataLen=160) and (fName=AnsiString(#0#0#0#0)) and (puint16(Mem)^<>NODE_MAGIC) then
      writeln('');  }
    if (puint16(Mem)^=NODE_MAGIC) or (puint16(Mem)^=NODE_COMPRESSED_MAGIC) then
    begin
      DeserializeFromMem(Mem);
      fData:=nil;
    end
      else
    begin
      GetMem(fData, fDataLen);
      Move(Mem^, fData^, fDataLen);
    end;
  end;

  if Data<>nil then
    FreeMem(Data, uncompSize);
end;

CDR (Content Description Record)
Содержится в BLOB-контейнере и имеет несколько основных потомков в корневом узле, расположение которых жестко прописано (у потомков аналогично):

  • 0 — версия файла (число, 16 бит);
  • 1 — записи приложений;
  • 2 — описание пакетов приложений;
  • 3, 4 — назначение так и не определено, поэтому просто игнорируются;
  • 5 — публичный ключи приложений;
  • 6 — зашифрованные приватные ключи.


Много ну очень скучных и длинных перечислений, можно даже не читать. Назначение части полей неясно до сих пор.
Записи приложений
Поля (так же узлы BLOB, по индексу):
  • 1 — ID приложения;
  • 2 — Название приложения;
  • 3 — Каталог приложения;
  • 4 — Минимальный размер файла кэша;
  • 5 — Максимальный размер файла кэша;
  • 6 — Содержит список параметров запуска;
  • 7 — Содержит список иконок приложения;
  • 8 — ID приложения. которое необходимо запустить при первом запуске;
  • 9 — флаг Is Bandwidth Greedy;
  • 10 — Список версий приложения;
  • 11 — ID текущей версии приложения;
  • 12 — Список файлов кэша приложения;
  • 13 — Номер тестовой версии;
  • 14 — Дополнительные поля в виде списка пар «имя-значение»;
  • 15 — пароль тестовой версии;
  • 16 — ID тестовой версии;
  • 17 — Оригинальная папка игры;
  • 18 — Флаг SkipMFPOverwrite;
  • 19 — Флаг UseFilesystemDvr.

Параметры запуска:
  • 1 — Описание;
  • 2 — Параметры командной строки;
  • 3 — Номер иконки;
  • 4 — Флаг, отвечающий за отсутствие ярлыка на рабочем столе;
  • 5 — Флаг, отвечающий за отсутствие ярлыка в меню «Пуск»;
  • 6 — Флаг Long Running Unattended.

Версии приложения:
  • 1 — Описание версии;
  • 2 — Номер версии;
  • 3 — Флаг, отвечающий за недоступность приложения данной версии;
  • 4 — Список ID параметров запуска для данной версии;
  • 5 — Ключ дешифрования для контента;
  • 6 — Флаг, указывающий наличие ключа дешифрования;
  • 7 — Флаг IsRebased;
  • 8 — Флаг IsLongVersionRoll.

Файлы кэша приложения:
  • 1 — ID файла кэша;
  • 2 — Имя монтируемого файла кэша;
  • 3 — Флаг, отвечающий за необязательность данного файла кэша.

Описание пакетов приложений
1 — ID пакета;
2 — Имя пакета;
3 — Тип пакета;
4 — Цена в центах;
5 — Какой-то там период в минутах;
6 — Список ID приложений данного пакета;
7 — ID запускаемого приложения (WTF?);
8 — Флаг OnSubscribeRunLaunchOptionIndex;
9 — Список RateLimitRecord;
10 — Список Discounts;
11 — Флаг предзаказа;
12 — Флаг, указывающий требование наличия физического адреса покупателя;
13 — Внутренняя цена в центах;
14 — Международная цена в центах;
15 — Тип требуемого ключа;
16 — Флаг, указывающий что данный пакет только для киберкафе;
17 — Некий игровой код;
18 — Описание этого кода;
19 — Флаг недоступности пакета;
20 — Флаг требования диска с игрой;
21 — Код территории. на которой эта игра доступна;
22 — Флаг, указывающий на то, что пакет доступен в 3-ей версии;
23 — Дополнительные поля в виде списка пар «имя-значение».


VDF
В файлах данного формата хранятся настройки клиента, а в нынешних версиях — еще и информация о приложениях. Может быть как двоичным, так и текстовым файлом.
Как и BLOB имеет древовидную структуру.
Рассмотрим бинарный файл. Есть несколько типов файлов, отличающихся структурой и заголовками, но формат узлов у них одинаковый.

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

  • 0 — содержит только подузлы;
  • 1 — строковые данные;
  • 2 — целое число;
  • 3 — дробное число;
  • 4 — указатель (на что??);
  • 5 — Unicode-строка;
  • 6 — цвет;
  • 7 — целое 64-битное число;
  • 8 — маркер конца списка узлов.


В случае чтения списка потомков узлов читаются узла, пока тип не станет равен 8.

Рассмотрим основные бинарные файлы, использующие бинарный вариант формата VDF.

appcache/appinfo.vdf
Сперва идет заголовок со следующим содержимым:

struct TVDFHeader
{
        uint8_t version1;
        uint16_t type;
        uint8_t version2;
        uint32_t version3;
};


Поля version1 и version2 ранее рассматривались как часть сигнатуры, но со временем и они изменились — раньше они были равны 0x24 и 0x06, теперь равны 0x26 и 0x07 соответственно.
Поле type является сигнатурой и содержит 0x4456 ('DV').
Поле version3 всегда содержит 0x00000001.

После заголовка идет список с информацией о приложении, каждый элемент которого имеет свой заголовок:

struct TVDFAppHeader
{
        uint32_t AppID;
        uint32_t DataSize;
};


После заголовка следует список параметров-узлов, содержащих 1 байт метки конца списка (0х00, если конец) и элемент VDF-дерева.appcache/packageinfo.vdf
Заголовок аналогичен предыдущему, только отличаются первые 3 поля:

  • version1 и version2 ранее содержали 0x25 и 0x06, теперь — 0x27 и 0x06;
  • type — 0x5556 ('UV').


После заголовка идет список узлов, описывающих пакеты приложений. Перед каждым элементом списка идет 4-х байтовое число, которое равно 0xFFFFFFFF, если достигнут конец списка.

Пример текстового файла VDF.

PAK


Устаревший формат архивов, используемый в первых версиях Half-Life 1. Никакого сжатия, это просто контейнер для файлов.
Заголовок файла:

struct TPAKHeader
{
        char Sign[4];
        uint32_t DirectoryOffset;
        uint32_t DirectoryLength;
};


Sign — сигнатура, содержит 'PACK'.
DirectoryOffset — смещение начала списка элементов.
DirectoryLength — размер списка элементов.

По указанному смещению находится массив заголовков элементов, содержащихся в архиве:

struct TPAKDirectoryItem
{
        char ItemName[56];
        uint32_t ItemOffset;
        uint32_t ItemLength;
};


Думаю, тут ничего описывать не надо, всё и так понятно.

VPK


Формат архивов игровых файлов, представленный в виде набора файлов, один из которых содержит описание расположения файлов, а остальные содержат непосредственно сами файлы. Корневой файл имеет имя вида "_dir.vpk", а остальные — "_.vpk".
Рассмотрим структуру корневого файла, начинающуюся со следующего заголовка:

struct TVPKHeader
{
        uint32_t Signature;
        uint32_t PaksCount;
        uint32_t DirSize;
}


Signature — всегда содержит 0x55aa1234.
PaksCount — количество архивов с содержимым файлов;
DirSize — размер данных с мета-информацией о файлах.

После заголовка следует иерархический список с элементами. Причем структура списка упорядочена по расширениям файлов и пути к ним.
То есть сперва идет NULL-terminated строка с расширением файла, потом NULL-terminated строка с путем, где такие файлы есть, после чего следует NULL-terminated строка имя файла (без расширения) с информацией о файле. Концом каждого уровня списка является пустая строка.

Пример псевдо-структуры, только строковая часть
bsp
hl2/maps
map1
map2
map3

wav
sound/amb
amb1
amb2

sound/voice
voice1
voice2


Формат информации о файле:

struct TVPKDirectoryEntry
{
        uint32_t CRC;
        uint16_t PreloadBytes;
        uint16_t ArchiveIndex;
        uint32_t EntryOffset
        uint32_t EntryLength
        uint16_t Dummy1;
};


CRC — контрольная сумма файла;
PreloadBytes — размер данных в начале файла, содержащихся в корневом файле после данной структуры;
ArchiveIndex — номер архива с данными файлами;
EntryOffset — смещение данных внутри архива;
EntryLength — размер данных.

Заключение


Вот и закончено описание всех форматов файлов Steam'а, кторые я вскрывал сам или с помощью материалов с форума cs.rin.ru (да-да, именно там сидели и вроде до сих пор сидят самые ярые англоязычные no-Steam-активисты). Только дописав данную статью, я понял, что её можно было смело включать в состав предыдущей — объем особо не увеличился бы, а так будет висеть мелкий огрызок…
Ну ничего, в следующей статье буду описывать работу Steam'а со всеми серверами (корневой, аутентификация, контент и т.п.). Рассматриваться будет уже устаревший протокол SteamNetwork2 (сейчас работает 3-я версия, основанная на HTTPS).

© Habrahabr.ru