Steam Files. Часть 2 — BLOB, CDR, VDF, PAK, VPK
После значительной задержки публикую продолжение цикла своих статей.
Для ознакомления:
В данной статье я затрону оставшиеся форматы файлов:
- 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 — читаем новый узел;
- В противном случае — сохраняем как просто данные.
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 строка имя файла (без расширения) с информацией о файле. Концом каждого уровня списка является пустая строка.
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).