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

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

Время на прочтение 9 мин
Количество просмотров 16K
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).
Теги:
Хабы:
+21
Комментарии 1
Комментарии Комментарии 1

Публикации

Истории

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

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн
PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн