Steam Files. Часть 1 — GCF/NCF

    Steam LogoКак и обещал в предыдущей статье, начинаю публиковать статьи о той части инфраструктуры Steam, которую смогло открыть Anti-Steam сообщество путём реверс-инжиниринга и продолжительных мозговых штурмов.

    Файлы формата GCF до недавнего времени являлись стандартом для всех игр, выпускаемых компанией VALVE, а NCF — для всех остальных. Сами по себе эти файлы представляют образ файловой системы с несколькими уровнями защиты. Отличие NCF от GCF заключается в том, что первые содержат только заголовки, а файлы, принадлежащие им, расположены в отдельном каталоге (<каталог Steam>/SteamApps/common/<имя игры>). Поэтому описывать буду GCF, а все особенности NCF приведу после.

    В данной статье я подробно разберу структуру данных файлов и работу с ними на примере своей библиотеки (ссылка на неё — в конце статьи). Начало будет достаточно скучным — описание структур и назначения их полей. Самое «вкусное» будет после них…

    Весь код, приведенный здесь, является плодом реверс-инжиниринга библиотек Steam. Большая часть информации о формате файлов была почерпнута из открытых источников, я же немного её дополнил и значительно оптимизировал работу с файлами кеша (даже по сравнению с самой популярной на то время библиотекой HLLIB).

    Общая структура файлов


    Файл логически разбит на 2 части — заголовки и непосредственно содержимое. Содержимое разбито на блоки, которые в свою очередь разбиты на сектора по 8кБ, принадлежность которых к определённым файлам и их последовательность описаны в заголовках. Все заголовки содержат поля, являющиеся четырёхбайтными целыми числами (исключение — часть, отвечающая за список имён файлов и каталогов).

    Заголовки состоят из следующих структур:
    • FileHeader
    • BlockAllocationTableHeader
    • BlockAllocationTable[]
    • FileAllocationTableHeader
    • FileAllocationTable[]
    • ManifestHeader
    • Manifest[]
    • FileNames
    • HashTableKeys[]
    • HashTableIndices[]
    • MinimumFootprints[]
    • UserConfig[]
    • ManifestMapHeader
    • ManifestMap[]
    • ChecksumDataContainer
    • FileIdChecksumTableHeader
    • FileIdChecksums[]
    • Checksums[]
    • ChecksumSignature
    • LatestApplicationVersion
    • DataHeader

    Первое же, что бросается в глаза — это ChecksumSignature, являющийся зашифрованным хешем части заголовков, отвечающей за контрольные суммы файлов.
    Все данные заголовки и назначение их полей будет рассмотрено далее.
    Для тех, кто читал не совсем внимательно, напомню, что все поля практически всех заголовков являются четырёхбайтными целыми числами (uint32_t в C++), если это не оговорено отдельно.

    FileHeader

    Исходя из названия, является заголовком всего файла и содержит следующие поля:
    • HeaderVersion
    • CacheType
    • FormatVersion
    • ApplicationID
    • ApplicationVersion
    • IsMounted
    • Dummy0
    • FileSize
    • ClusterSize
    • ClusterCount
    • Checksum

    HeaderVersion — всегда равно 0x00000001, указывая на версию данного заголовка.
    CacheType — равно 0x00000001 для GCF и 0x00000002 для NCF.
    FormatVersion — указывает на версию структуры остальных заголовков. Последняя версия — 6. Она и будет описана далее.
    ApplicationID — идентификатор файла (AppID).
    ApplicationVersion — версия содержимого файла. Служит для контролем за необходимостью обновления.
    IsMounted — содержит 0x00000001, если файл в данный момент примонтирован другим приложением. В настоящее время не используется, поэтому всегда равно 0x00000000.
    Dummy0 — выравнивающее поле, содержащее 0x00000000.
    FileSize — общий размер файла. Если превышает 4Гб, то данное поле содержит разницу <размер файла>-ffffffff, а сам размер файла вычисляется исходя из
    размера блока данных и их количества.
    ClusterSize — размер блока данных в содержимом. Для GCF содержит 0x00002000, а для NCF — 0x00000000.
    ClusterCount — количество блоков данных в содержимом.
    Checksum — контрольная сумма заголовка. Вычисляется следующей функцией:

    UINT32 HeaderChecksum(UINT8 *lpData, int Size)
    {
    	UINT32 Checksum = 0;
    	for (int i=0 ; i<Size ; i++)
    		Checksum += *(lpData++);
    	return Checksum;
    }

    Первым параметром передаётся указатель на структуру, а вторым — её размер, за исключением поля Checksum (то есть меньше на 4).

    BlockAllocationTableHeader

    Содержит описание таблицы блоков (не секторов!):
    • BlockCount
    • BlocksUsed
    • LastUsedBlock
    • Dummy0
    • Dummy1
    • Dummy2
    • Dummy3
    • Checksum

    BlockCount — содержит общее количество блоков в файле.
    BlocksUsed — количество используемых блоков. Всегда меньше общего количества блоков. Если приближается к нему — значение общего количества увеличивается, что вызывает перестроение всех последующих заголовков и перемещение первого сектора данных в конец файла для высвобождения места под заголовки.
    LastUsedBlock — индекс последнего используемого блока.
    Dummy0, Dummy1, Dummy2, Dummy2 — выравнивающие поля, содержат 0x00000000.
    Checksum — контрольная сумма заголовка. Содержит сумму всех предыдущих полей.

    BlockAllocationTable

    Является массивом структур BlockAllocationTableEntry, количество которых равно общему количеству блоков (BlockAllocationTableHeader.BlockCount):
    • uint16_t Flags
    • uint16_t Dummy0
    • FileDataOffset
    • FileDataSize
    • FirstClusterIndex
    • NextBlockIndex
    • PreviousBlockIndex
    • ManifestIndex

    Flags — содержит битовые флаги блока. Возможные маски:
    • 0x8000 — блок используется;
    • 0x4000 — локальная копия файла имеет приоритет;
    • 0x0004 — блок зашифрован;
    • 0x0002 — блок зашифрован и сжат;
    • 0x0001 — блок содержит некие «сырые» данные (RAW).

    Dummy0 выравнивающее поле, содержит 0x0000.
    FileDataOffset содержит смещение данного блока относительно файла, к которому он принадлежит.
    FileDataSize — размер фрагмента файла, хранящегося в данном блоке.
    FirstClusterIndex — индекс первого кластера в таблице кластеров.
    NextBlockIndex — индекс следующего блока. Содержит значение BlockAllocationTableHeader. BlockCount, если это последний блок в цепочке для данного файла.
    PreviousBlockIndex — содержит индекс предыдущего блока в цепочке. Если он первый, то содержит значение BlockAllocationTableHeader. BlockCount.
    ManifestIndex — индекс манифеста для данного блока.
    Индексом таблицы выступает номер блока из списка ManifestMap.

    FileAllocationTableHeader

    Заголовок таблицы секторов:
    • ClusterCount
    • FirstUnusedEntry
    • IsLongTerminator
    • Checksum

    ClusterCount — содержит количество секторов. Содержит значение, равное FileHeader.ClusterCount.
    FirstUnusedEntry — индекс первого неиспользуемого сектора.
    IsLongTerminator — определяет значение, являющееся индикатором конца цепочки секторов. Если содержит 0x00000000, то терминатором является значение 0x0000FFFF, иначе — 0xFFFFFFFF.
    Checksum — контрольная сумма заголовка. Как и для BlockAllocationTableHeader, является суммой предыдущих полей заголовка.

    FileAllocationTable

    Таблица секторов, содержащая FileAllocationTableHeader.ClusterCount записей типа uint32_t. Каждая ячейка содержит индекс следующего кластера в цепочке или значение терминатора (смотрите объявление FileAllocationTableHeader, если является последним в цепочке.
    Индексом списка является номер сектора.

    ManifestHeader

    Содержит описание таблицы манифестов:
    • HeaderVersion
    • ApplicationID
    • ApplicationVersion
    • NodeCount
    • FileCount
    • CompressionBlockSize
    • BinarySize
    • NameSize
    • HashTableKeyCount
    • NumOfMinimumFootprintFiles
    • NumOfUserConfigFiles
    • Bitmask
    • Fingerprint
    • Checksum

    HeaderVersion — версия заголовка. Содержит 0x00000004.
    ApplicationID — идентификатор файла. Равен FileHeader.ApplicationID.
    ApplicationVersion — версия содержимого файла. Равен FileHeader.ApplicationVersion.
    NodeCount — количество элементов манифеста.
    FileCount — количество файлов, объявленных в манифесте (и содержащееся в кеше).
    CompressionBlockSize — максимальный размер сжатого блока (его несжатых данных).
    BinarySize — размер манифеста (включая данную структуру).
    NameSize — размер блока данных, содержащего имена элементов (в байтах).
    HashTableKeyCount — количество значений в таблице хешей.
    NumOfMinimumFootprintFiles — количество файлов, минимально необходимых для запуска приложения (которые необходимо распаковать на диск).
    NumOfUserConfigFiles — количество файлов пользовательской конфигурации. При наличии данного файла на диске он не перезаписывается при запуске игры и имеет больший приоритет.
    Bitmask — содержит битовые маски. В публичных версиях файлов всегда содержит 0x00000000.
    Fingerprint — уникальное число, случайно генерируемое при каждом обновлении манифеста.
    Checksum — контрольная сумма. Рассчитывается по алгоритму Adler32. Алгоритм расчета будет приведён после описания заголовков.

    Manifest

    Дерево, содержащее описание всех файлов в кеше. Размер таблицы равен значению ManifestHeader.NodeCount. Все элементы таблицы представлены следующими структурами:
    • NameOffset
    • CountOrSize
    • FileId
    • Attributes
    • ParentIndex
    • NextIndex
    • ChildIndex

    NameOffset — смещение имени элемента в соответствующем блоке данных.
    CountOrSize — размер элемента. Для каталогов равен количеству дочерних элементов, а для файлов — непосредственно размеру файла (или части файла, описываемой данным манифестом).
    FileId — идентификатор файла. Служит для связывания нескольких манифестов для больших файлов и поиска списка контрольных сумм.
    Attributes — битовое поле атрибутов файла. Возможные значения (из подтверждённых):

    • 0x00004000 — узел является файлом;
    • 0x00000100 — зашифрованный файл;
    • 0x00000001 — конфигурационный файл. Локальная копия не перезаписывается.

    ParentIndex — индекс родительского элемента. Для корневого элемента равен 0xFFFFFFFF.
    NextIndex — индекс следующего элемента на текущем уровне дерева.
    ChildIndex — индекс первого дочернего элемента.
    Если для NextIndex и ChildIndex нет элементов, то они содержат значение 0x00000000.
    Дерево обязательно содержит как минимум один элемент — корневой.
    Индексом списка, содержащего элементы дерева, является номер элемента (используется в дальнейшем)

    FileNames

    Блок данных типа char, размером ManifestHeader.NameSize байт. Содержит нуль-терминированные строки, являющиеся именами элементов, описываемых в дереве манифестов. Обязательным является наличие первого, корневого элемента — пустой строки. Смещение имён элементов задаётся значением Manifest[].NameOffset

    HashTableKeys

    Содержит хеш-таблицу имён элементов. Содержит значения индексов для HashTableIndices, распределенных по индексам, являющимися производным от хеш-функции Дженкинса lookup2 для строк, приведённых к нижнему регистру. Подробнее будет рассмотрено при описании поиска элементов.

    HashTableIndices

    Содержит таблицу индексов элементов, на которые ссылаются значения из предыдущей таблицы. Количество элементов — ManifestHeader.NodeCount.

    MinimumFootprints

    Содержит список номеров элементов в Manifest, которые необходимо распаковать при запуске приложения.

    UserConfigs

    Содержит список номеров элементов в Manifest, являющихся файлами пользовательской конфигурации.

    ManifestMapHeader

    Заголовок карты манифестов:
    • HeaderVersion
    • Dummy0

    HeaderVersion — версия заголовка. Равна 0x00000001.
    Dummy0 — выравнивающее значение. Содержит 0x00000000.

    ManifestMap

    Содержит таблицу ссылок на первый блок (структура BlockAllocationTable) для каждого элемента. Индексом элементов является номер элемента в дереве манифестов. Для каталогов и файлов, не сохранённых в кеше (имеющих нулевой размер или для NCF), содержит значение, равное BlockAllocationTableHeader.BlockCount.

    ChecksumDataContainer

    Заголовок контейнера, хранящего контрольные суммы:
    • HeaderVersion
    • ChecksumSize

    HeaderVersion — версия заголовка. Равна 0x00000001.
    ChecksumSize — размера контейнера. Вычисляется от следующей структуры и по LatestApplicationVersion включительно.

    FileIdChecksumTableHeader

    Заголовок таблицы индексов контрольных сумм:
    • FormatCode
    • Dummy0
    • FileIdCount
    • ChecksumCount

    FormatCode — некая константа. Равна 0x14893721.
    Dummy0 — выравнивающее поле. Содержит значение 0x00000001.
    FileIdCount — количество элементов в таблице «элемент-перый_хеш».
    ChecksumCount — количество элементов в списке контрольных сумм.

    FileIdChecksums

    Таблица, связывающая файлы со списком контрольных сумм:
    • ChecksumCount
    • FirstChecksumIndex

    ChecksumCount — количество контрольных сумм в списке для данного элемента.
    FirstChecksumIndex — индекс первой контрольной суммы в списке.
    Индексом является значение Manifest[].FileId.

    Checksums

    Список контрольных сумм. Содержит последовательные подсписки, на первый элемент которых ссылается значение FileIdChecksums[].FirstChecksumIndex.
    Значения рассчитываются по следующему алгоритму:

    UINT32 Checksum(UINT8 *lpData, UINT32 uiSize)
    {
    	return (adler32(0, lpData, uiSize) ^ crc32(0, lpData, uiSize));
    }


    ChecksumSignature

    Сигнатура блока контрольных сумм. Содержит значение хеша для блока контрольных сумм, рассчитанное по алгоритму SHA-1 и зашифрованное алгоритмом RSASSA-PKCS1-v1_5.

    LatestApplicationVersion

    Данное поле содержит версию блока контрольных сумм. Обновляется до актуальной после каждого обновления содержимого.

    DataHeader

    Заголовок, описывающий физическое размещение данных в кеше:
    • ClusterCount
    • ClusterSize
    • FirstClusterOffset
    • ClustersUsed
    • Checksum

    ClusterCount — количество секторов. Значение равно полю FileHeader.ClusterCount.
    ClusterSize — размер сектора. Значение равно полю FileHeader.ClusterSize.
    FirstClusterOffset — смещение первого сектора относительно начала файла.
    ClustersUsed — количество используемых секторов.
    Checksum — контрольная сумма заголовка. Равна сумме предшествующих полей заголовка.
    После обновления содержимого количество используемых секторов могло уменьшится. В таких случаях освободившиеся сектора переносились в конец файла для резервирования места под будущие обновления.

    Алгоритмы


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

    Расчет размера файла

    В большинстве случаев размер файла равен значению поля Manifest[].CountOrSize. Но для файлов размером более 4Гб такой путь не подходит. Программисты VALVE обошли это следующим путём: для файлов размером более 2Гб устанавливаем старший бит этого поля в «1» и заводим в списке ещё один (или несколько) элементов с такими же значениями остальных полей, получая своеобразную цепочку. Суммируя значение полей Manifest[].CountOrSize из данной цепочки мы и подсчитаем итоговый размер файла.

    Код подсчёта размера файла
    UINT64 CGCFFile::GetFileSize(UINT32 Item)
    {
    	UINT64 res = lpManifest[Item].CountOrSize & 0x7FFFFFFF;
    	if ((lpManifest[Item].CountOrSize & 0x80000000) != 0)
    	{
    		for (UINT32 i=0 ; i<pManifestHeader->NodeCount ; i++)
    		{
    			ManifestNode *MN = &lpManifest[Item];
    			if (((MN->Attributes & 0x00004000) != 0) && (MN->ParentIndex == 0xFFFFFFFF) &&
    				(MN->NextIndex == 0xFFFFFFFF) && (MN->ChildIndex == 0xFFFFFFFF) && (MN->FileId == lpManifest[Item].FileId))
    			{
    				res += MN->CountOrSize << 31;
    				break;
    			}
    		}
    	}
    	return res;
    }

    Здесь я сделал небольшой «финт ушами», допустив, что файлы размером более 4Гб всё-таки не будут входить в состав кеша…

    Поиск элемента по имени

    например, нам надо найти файл с именем «hl2/maps/background_01.bsp». Все имена у нас хранятся в древовидном виде, поэтому путь придётся разбивать на элементы, связанные разделителем (в данном случае — "/"). Затем мы ищем у потомков корневого элемента элемент с именем «hl2». У него — элемента с именем «maps», и только затем — элемент с именем «background_01.bsp». Данный путь самый очевидный, но очень медленный — происходит побайтовой сравнение строк, да ещё и обход по дереву. Сплошные затраты.
    Для ускорения данной процедуры в заголовках есть хеш-таблицы.

    Поиск элемента по имени с использование хеша
    C++
    UINT32 CGCFFile::GetItem(char *Item)
    {
    	int DelimiterPos = -1;
    	for (UINT32 i=0 ; i<strlen(Item) ; i++)
    		if (Item[i] == '\\')
    			DelimiterPos = i;
    	char *FileName = &Item[++DelimiterPos];
    	UINT32 Hash = jenkinsLookupHash2((UINT8*)FileName, strlen(FileName), 1),
    		HashIdx = Hash % pManifestHeader->HashTableKeyCount,
    		HashFileIdx = lpHashTableKeys[HashIdx];
    	if (HashFileIdx == CACHE_INVALID_ITEM)
    		if (strcmp(LowerCase(Item), Item) != 0)
    		{
    			Hash = jenkinsLookupHash2((UINT8*)LowerCase(Item), strlen(FileName), 1);
    			HashIdx = Hash % pManifestHeader->HashTableKeyCount;
    			HashFileIdx = lpHashTableKeys[HashIdx];
    		}
    	if (HashFileIdx == CACHE_INVALID_ITEM)
    		return CACHE_INVALID_ITEM;
    
    	HashFileIdx -= pManifestHeader->HashTableKeyCount;
    	while (true)
    	{
    		UINT32 Value = this->lpHashTableIndices[HashFileIdx];
    		UINT32 FileID = Value & 0x7FFFFFFF;
    		if (strcmp(GetItemPath(FileID), Item) == 0)
    			return FileID;
    		if ((Value & 0x80000000) == 0x80000000)
    			break;
    		HashFileIdx++;
    	}
    
    	return CACHE_INVALID_ITEM;
    }

    Delphi
    function TGCFFile.GetItemByPath(Path: string): integer;
    var
      end_block: boolean;
      Hash, HashIdx, HashValue: ulong;
      FileID, HashFileIdx: integer;
      PathEx: AnsiString;
    begin
      result:=-1;
    {$IFDEF UNICODE}
      PathEx:=Wide2Ansi(ExtractFileName(Path));
    {$ELSE}
      PathEx:=ExtractFileName(Path);
    {$ENDIF}
      Hash:=jenkinsLookupHash2(@PathEx[1], Length(PathEx), 1);
      HashIdx:=Hash mod fManifestHeader.HashTableKeyCount;
      HashFileIdx:=lpHashTableKeys[HashIdx];
      if HashFileIdx=-1 then
      begin
        if (LowerCase(Path)<>Path) then
        begin
    {$IFDEF UNICODE}
          Hash:=jenkinsLookupHash2(@LowerCaseAnsi(PathEx)[1], Length(PathEx), 1);
    {$ELSE}
          Hash:=jenkinsLookupHash2(@LowerCase(PathEx)[1], Length(PathEx), 1);
    {$ENDIF}
          HashIdx:=Hash mod fManifestHeader.HashTableKeyCount;
          HashFileIdx:=lpHashTableKeys[HashIdx];
          if HashFileIdx=-1 then
            Exit;
        end;
      end;
      dec(HashFileIdx, fManifestHeader.HashTableKeyCount);
      repeat
        HashValue:=lpHashTableIndices[HashFileIdx];
        FileID:=HashValue and $7FFFFFFF;
        end_block:= (HashValue and $80000000 = $80000000);
        if CompareStr(ItemPath[FileID], Path)=0 then
        begin
          result:=FileID;
          Exit;
        end;
        inc(HashFileIdx);
      until end_block;
    
      if (result=-1) and (LowerCase(Path)<>Path) then
        result:=GetItemByPath(LowerCase(Path));
    end;

    Как видно из кода, из всего пути к файлу мы берем только его имя и рассчитываем хеш для него. Берём остаток от целочисленного деления результата на значение ManifestHeader.HashTableKeyCount — это будет номер записи в списке HashTableKeys, содержащей либо 0xffffffff (если нет такого элемента) или значение X+ManifestHeader.HashTableKeyCount. Исходя из этого вычисляем X, являющийся номером элемента в списке HashTableIndices, с которого может находиться искомый элемент. Значения из этого списка указывают на искомый элемент, имя которого сравнивается в запросом. Если не совпало — берём следующий элемент списка и повторяем до тех пор, пока старший бит номера элемента равен «0».
    Понимаю, что получилось запутанно, но именно так оно и работает… Вините в подобной путанице программистов VALVE.
    Данный метод значительно лучше прямого поиска по дереву — сравнивалась производительность при запуске игры с самописной библиотекой-эмулятором Steam.dll, о которой ещё будет разговор.

    Получение полного пути к элементу

    Данное действие несколько обратно предыдущему — по номеру элемента надо пройтись по дереву до корневого элемента и получить путь к файлу.

    Получение пути к файлу
    C++
    char *CGCFFile::GetItemPath(UINT32 Item)
    {
    	size_t len = strlen(&lpNames[lpManifest[Item].NameOffset]);
    	UINT32 Idx = lpManifest[Item].ParentIndex;
    	while (Idx != CACHE_INVALID_ITEM)
    	{
    		len += strlen(&lpNames[lpManifest[Idx].NameOffset]) + 1;
    		Idx= lpManifest[Idx].ParentIndex;
    	}
    	len--;
    
    	char *res = new char[len+1];
    	memset(res, 0, len+1);
    	size_t l = strlen(&lpNames[lpManifest[Item].NameOffset]);
    	memcpy(&res[len-l], &lpNames[lpManifest[Item].NameOffset], l);
    	len -= strlen(&lpNames[lpManifest[Item].NameOffset]);
    	res[--len] = '\\';
    	Item = lpManifest[Item].ParentIndex;
    	while ((Item != CACHE_INVALID_ITEM) && (Item != 0))
    	{
    		l = strlen(&lpNames[lpManifest[Item].NameOffset]);
    		memcpy(&res[len-l], &lpNames[lpManifest[Item].NameOffset], l);
    		len -= strlen(&lpNames[lpManifest[Item].NameOffset]);
    		res[--len] = '\\';
    		Item = lpManifest[Item].ParentIndex;
    	}
    	return res;
    }

    Delphi
    function TGCFFile.GetItemPath(Item: integer): string;
    var
      res: AnsiString;
    begin
      res:=pAnsiChar(@fNameTable[lpManifestNodes[Item].NameOffset+1]);
      Item:=lpManifestNodes[Item].ParentIndex;
      while (Item>-1) do
      begin
        res:=pAnsiChar(@fNameTable[lpManifestNodes[Item].NameOffset+1])+'\'+res;
        Item:=lpManifestNodes[Item].ParentIndex;
      end;
      Delete(res, 1, 1);
    {$IFDEF UNICODE}
      result:=Ansi2Wide(res);
    {$ELSE}
      result:=res;
    {$ENDIF}
    end;

    Код для Delphi значительно меньше из-за того, что для C++ я не использовал класс std::string — не знал про него тогда. С ним код вышел бы значительно короче...

    Потоки

    При написании библиотек для архиво-подобных форматов файлов (которые содержат в себе другие файлы) я использую принцип «поток-в-потоке», что позволяет открывать файлы в архиве, не распаковывая его. Например, в кеше half-life.gcf старых версий был файл pak0.pak, являющийся архивом. В итоге я открывал файл half-life.gcf, в нём — pak0.pak. в котором в свою очередь читал необходимые файлы. И всё это — без распаковки даже в память, весь функционал реализуется через написанные мною же обёртки над файловыми потоками (низкоуровневыми, на уровне WindowsAPI).

    Открытие файла в кеше
    C++
    CStream *CGCFFile::OpenFile(char* FileName, UINT8 Mode)
    {
    	UINT32 Item = GetItem(FileName);
    	if (Item == CACHE_INVALID_ITEM)
    		return NULL;
    	if ((lpManifest[Item].Attributes & CACHE_FLAG_FILE) != CACHE_FLAG_FILE)
    		return NULL;
    	return OpenFile(Item, Mode);
    }
    
    CStream *CGCFFile::OpenFile(UINT32 Item, UINT8 Mode)
    {
    	StreamData *Data = new StreamData();
    	memset(Data, 0, sizeof(StreamData));
    	Data->Handle = (handle_t)Item;
    	Data->Package = this;
    	Data->Size = this->GetItemSize(Item).Size;
    
    	if (IsNCF)
    		Data->FileStream = (CStream*)new CStream(MakeStr(CommonPath, GetItemPath(Item)), Mode==CACHE_OPEN_WRITE);
    	else
    		BuildClustersTable(Item, &Data->Sectors);
    
    	return new CStream(pStreamMethods, Data);
    }

    Delphi
    function TGCFFile.OpenFile(FileName: string; Access: byte): TStream;
    var
      Item: integer;
    begin
      result:=nil;
      Item:=ItemByPath[FileName];
      if (Item=-1) then
        Exit;
      if ((lpManifestNodes[Item].Attributes and HL_GCF_FLAG_FILE<>HL_GCF_FLAG_FILE) or
       (ItemSize[Item].Size=0)) then
        Exit;
    
      result:=OpenFile(Item, Access);
    end;
    
    function TGCFFile.OpenFile(Item: integer; Access: byte): TStream;
    var
      res: TStream;
    begin
      res:=TStream.CreateStreamOnStream(@StreamMethods);
      res.Data.fHandle:=ulong(Item);
      res.Data.Package:=self;
      res.Data.fSize:=(res.Data.Package as TGCFFile).ItemSize[Item].Size;
      res.Data.fPosition:=0;
    
      if (IsNCF) then
      begin
        CommonPath:=IncludeTrailingPathDelimiter(CommonPath);
        case Access of
          ACCES_READ:
            begin
              res.Data.FileStream:=TStream.CreateReadFileStream(CommonPath+ItemPath[Item]);
              res.Methods.fSetSiz:=StreamOnStream_SetSizeNULL;
              res.Methods.fWrite:=StreamOnStream_WriteNULL;
            end;
          ACCES_WRITE:
            begin
              ForceDirectories(ExtractFilePath(CommonPath+ItemPath[Item]));
              res.Data.FileStream:=TStream.CreateWriteFileStream(CommonPath+ItemPath[Item]);
            end;
          ACCES_READWRITE: res.Data.FileStream:=TStream.CreateReadWriteFileStream(CommonPath+ItemPath[Item]);
        end;
        res.Data.FileStream.Seek(0, spBegin);
      end
        else GCF_BuildClustersTable(Item, @res.Data.SectorsTable);
    
      result:=res;
    end;

    Таким образом значительно упрощается работа с содержимым — можно открывать файлы и читать данные из них без лишних телодвижений.

    Извлечение файла с проверкой контрольной суммы

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

    Извлечение файла с проверкой его КС
    C++
    UINT64 CGCFFile::ExtractFile(UINT32 Item, char *Dest, bool IsValidation)
    {
    	CStream *fileIn = this->OpenFile(Item, CACHE_OPEN_READ),
    		*fileOut;
    	if (fileIn == NULL)
    		return 0;
    	if (!IsValidation)
    	{
    		if (DirectoryExists(Dest))
    			Dest = MakeStr(IncludeTrailingPathDelimiter(Dest), GetItemName(Item));
    		fileOut = new CStream(Dest, true);
    		if (fileOut->GetHandle() == INVALID_HANDLE_VALUE)
    			return 0;
    		fileOut->SetSize(GetItemSize(Item).Size);
    	}
    
    	UINT8 buf[CACHE_CHECKSUM_LENGTH];
    	UINT32 CheckSize = CACHE_CHECKSUM_LENGTH;
    	UINT64 res = 0;
    	while ((fileIn->Position()<fileIn->GetSize()) && (CheckSize == CACHE_CHECKSUM_LENGTH))
    	{
    		if (Stop)
    			break;
    		UINT32 CheckIdx = lpFileIDChecksum[lpManifest[Item].FileId].FirstChecksumIndex + ((fileIn->Position() & 0xffffffffffff8000) >> 15);
    		CheckSize = (UINT32)fileIn->Read(buf, CheckSize);
    
    		UINT32 CheckFile = Checksum(buf, CheckSize),
    			CheckFS = lpChecksum[CheckIdx];
    		if (CheckFile != CheckFS)
    		{
    			break;
    		}
    		else if (!IsValidation)
    		{
    			fileOut->Write(buf, CheckSize);
    		}
    
    		res += CheckSize;
    	}
    	delete fileIn;
    	if (!IsValidation)
    		delete fileOut;
    	return res;
    }

    Delphi
    function TGCFFile.ExtractFile(Item: integer; Dest: string; IsValidation: boolean = false): int64;
    var
      StreamF, StreamP: TStream;
      CheckSize, CheckFile, CheckFS, CheckIdx: uint32_t;
      buf: array of byte;
      Size: int64;
    begin
      result:=0;
      StreamP:=OpenFile(Item, ACCES_READ);
      if (StreamP=nil) then
        Exit;
    
      Size:=ItemSize[Item].Size;
      if Assigned(OnProgress) then
        OnProgress(ItemPath[Item], 0, Size, Data);
      if Assigned(OnProgressObj) then
        OnProgressObj(ItemPath[Item], 0, Size, Data);
    
      StreamF:=nil;
      if (not IsValidation) then
      begin
        if DirectoryExists(Dest) then
          Dest:=IncludeTrailingPathDelimiter(Dest)+ExtractFileName(ItemName[Item]);
        StreamF:=TStream.CreateWriteFileStream(Dest);
        StreamF.Size:=ItemSize[Item].Size;
        if StreamF.Handle=INVALID_HANDLE_VALUE then
        begin
          StreamF.Free;
          Exit;
        end;
      end;
    
      SetLength(buf, HL_GCF_CHECKSUM_LENGTH);
      CheckSize:=HL_GCF_CHECKSUM_LENGTH;
      while ((StreamP.Position<StreamP.Size) and (CheckSize=HL_GCF_CHECKSUM_LENGTH)) do
      begin
        CheckIdx:=lpFileIdChecksumTableEntries[lpManifestNodes[Item].FileId].FirstChecksumIndex+
         ((StreamP.Position and $ffffffffffff8000) shr 15);
        CheckSize:=StreamP.Read(buf[0], HL_GCF_CHECKSUM_LENGTH);
    
        CheckFile:=Checksum(@buf[0], CheckSize);
        CheckFS:=lpChecksumEntries[CheckIdx];
        if (CheckFile<>CheckFS) and (not IgnoreCheckError) then
        begin
          if Assigned(OnError) then
            OnError(GetItemPath(Item), ERROR_CHECKSUM, Data);
          if Assigned(OnErrorObj) then
            OnErrorObj(GetItemPath(Item), ERROR_CHECKSUM, Data);
          break;
        end
          else if (not IsValidation) then
            StreamF.Write(buf[0], CheckSize);
        inc(result, CheckSize);
    
        if Assigned(OnProgress) then
          OnProgress('', result, Size, Data);
        if Assigned(OnProgressObj) then
          OnProgressObj('', result, Size, Data);
        if Stop then
          break;
      end;
      SetLength(buf, 0);
      StreamP.Free;
      if (not IsValidation) then
        StreamF.Free;
    end;

    В коде для Delphi присутствует дополнительный код для отображения прогресса работы — вызов callback-функций OnProgress, OnProgressObj.

    Дешифрование содержимого файлов

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

    Дешифрование файла
    C++
    UCHAR IV[16] = {0};
    void DecryptFileChunk(char *buf, UINT32 size, char *key)
    {
    	AES_KEY aes_key;
    	AES_set_decrypt_key((UCHAR*)key, 128, &aes_key);
    	AES_cbc_encrypt((UCHAR*)buf, (UCHAR*)buf, size, &aes_key, IV, false);
    }
    
    UINT64 CGCFFile::DecryptFile(UINT32 Item, char *key)
    {
    	UINT64 res = 0;
    	CStream *str = OpenFile(Item, CACHE_OPEN_READWRITE);
    	if (str == NULL)
    		return 0;
    	char buf[CACHE_CHECKSUM_LENGTH],
    		dec[CACHE_CHECKSUM_LENGTH];
    	UINT32 CheckSize = CACHE_CHECKSUM_LENGTH;
    	INT32 CompSize,
    		UncompSize,
    		sz;
    	while ((str->Position() < str->GetSize()) && (CheckSize == CACHE_CHECKSUM_LENGTH))
    	{
    		UINT32 CheckIdx = lpFileIDChecksum[lpManifest[Item].FileId].FirstChecksumIndex +
    			((str->Position() & 0xffffffffffff8000) >> 15);
    		INT32 CheckSize = (INT32)str->Read(buf, 8);
    
    		memcpy(&CompSize, &buf[0], 4);
    		memcpy(&UncompSize, &buf[4], 4);
    		if (((UINT32)UncompSize > pManifestHeader->CompressionBlockSize) || (CompSize > UncompSize) || (UncompSize < -1) || (CompSize < -1))
    		{
    			// Chunk is not compressed
    			CheckSize = (UINT32)str->Read(&buf[8], CACHE_CHECKSUM_LENGTH-8);
    			DecryptFileChunk(&buf[0], CheckSize, key);
    		}
    		else if (((UINT32)UncompSize <= pManifestHeader->CompressionBlockSize) && (CompSize <= UncompSize) && (UncompSize > -1) || (CompSize > -1))
    		{
    			// Chunk is compressed
    			CheckSize = (UINT32)str->Read(&buf[8], UncompSize-8);
    			INT32 CheckFile = UncompSize;
    			if (CompSize%16 == 0)
    				sz = CompSize;
    			else
    				sz = CompSize + 16 - (CompSize%16);
    			memcpy(dec, buf, sz);
    			DecryptFileChunk(&dec[0], sz, key);
    			uncompress((Bytef*)&buf[0], (uLongf*)&CheckFile, (Bytef*)&dec[0], sz);
    		}
    		str->Seek(-CheckSize, USE_SEEK_CURRENT);
    		str->Write(&buf[0], CheckSize);
    
    		UINT32 Check1 = Checksum((UINT8*)&buf[0], CheckSize),
    			Check2 = lpChecksum[CheckIdx];
    		if (Check1 != Check2)
    			break;
    		res += CheckSize;
    	}
    
    	lpManifest[Item].Attributes = lpManifest[Item].Attributes & (!CACHE_FLAG_ENCRYPTED);
    	return res;
    }

    Delphi
    const
      IV: array[0..15] of byte = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0);
    
    procedure DecryptFileChunk(buf: pByte; ChunkSize: integer; Key: Pointer);
    var
      AES: TCipher_Rijndael;
      src: array[0..HL_GCF_CHECKSUM_LENGTH-1] of byte;
    begin
      Move(buf^, src[0], HL_GCF_CHECKSUM_LENGTH);
      AES:=TCipher_Rijndael.Create();
      AES.Init(Key^, 16, IV[0], 16);
      AES.Mode:=cmCFBx;
      AES.Decode(src[0], buf^, ChunkSize);
      AES.Free;
    end;
    
    function TGCFFile.DecryptFile(Item: integer; Key: Pointer): int64;
    var
      StreamP: TStream;
      CheckSize, CheckFile, CheckFS, CheckIdx, sz: uint32_t;
      buf: array of byte;
      dec: array[0..HL_GCF_CHECKSUM_LENGTH] of byte;
      CompSize, UncompSize: integer;
      Size: int64;
    begin
      result:=0;
      StreamP:=OpenFile(Item, ACCES_READWRITE);
      if (StreamP=nil) then
        Exit;
    
      Size:=ItemSize[Item].Size;
      if Assigned(OnProgress) then
        OnProgress(ItemName[Item], 0, Size, Data);
      if Assigned(OnProgressObj) then
        OnProgressObj(ItemName[Item], 0, Size, Data);
    
      SetLength(buf, HL_GCF_CHECKSUM_LENGTH);
      CheckSize:=HL_GCF_CHECKSUM_LENGTH;
      while ((StreamP.Position<StreamP.Size) and (CheckSize=HL_GCF_CHECKSUM_LENGTH)) do
      begin
        CheckIdx:=lpFileIdChecksumTableEntries[lpManifestNodes[Item].FileId].FirstChecksumIndex+
         ((StreamP.Position and $ffffffffffff8000) shr 15);
        CheckSize:=StreamP.Read(buf[0], 8);
    
        Move(buf[0], CompSize, 4);
        Move(buf[4], UncompSize, 4);
        if (ulong(UncompSize)>fManifestHeader.CompressionBlockSize) or (CompSize>UncompSize) or (UncompSize<-1) or (CompSize<-1) then
        begin
          //Chunk is not compressed!
          CheckSize:=StreamP.Read(buf[8], HL_GCF_CHECKSUM_LENGTH-8);
          DecryptFileChunk(@buf[0], CheckSize, Key);
        end
          else if ((ulong(UncompSize)<=fManifestHeader.CompressionBlockSize) and (CompSize<=UncompSize)) and ((UncompSize>-1) and (CompSize>-1)) then
        begin
          CheckSize:=StreamP.Read(buf[8], UncompSize-8);
          CheckFile:=UncompSize;
          //Chunk is compressed!
          if (CompSize mod 16=0) then sz:=CompSize
            else sz:=CompSize+16-(CompSize mod 16);
          Move(buf[8], dec[0], sz);
          DecryptFileChunk(@dec[0], sz, Key);
          uncompress(@buf[0], CheckFile, @dec[0], sz);
        end;
        StreamP.Seek(-CheckSize, spCurrent);
        StreamP.Write(buf[0], CheckSize);
    
    
        CheckFile:=Checksum(@buf[0], CheckSize);
        CheckFS:=lpChecksumEntries[CheckIdx];
        if (CheckFile<>CheckFS) and (not IgnoreCheckError) then
        begin
          if Assigned(OnError) then
            OnError(GetItemPath(Item), ERROR_CHECKSUM, Data);
          if Assigned(OnErrorObj) then
            OnErrorObj(GetItemPath(Item), ERROR_CHECKSUM, Data);
          break;
        end;
        inc(result, CheckSize);
    
        //StreamP.Position:=StreamP.Position+CheckSize;
    
        if Assigned(OnProgress) then
          OnProgress('', result, Size, Data);
        if Assigned(OnProgressObj) then
          OnProgressObj('', result, Size, Data);
        if Stop then
          break;
      end;
      lpManifestNodes[Item].Attributes:=lpManifestNodes[Item].Attributes and (not HL_GCF_FLAG_ENCRYPTED);
      fIsChangeHeader[HEADER_MANIFEST_NODES]:=true;
      SaveChanges();
      SetLength(buf, 0);
    end;


    Расчет контрольной суммы для ManifestHeader

    Для расчёта данного значения используются следующие структуры заголовков:
    • ManifestHeader
    • Manifest[]
    • FileNames
    • HashTableKeys[]
    • HashTableIndices[]
    • MinimumFootprints[]
    • UserConfig[]

    Перед расчётом КС обнуляются следующие поля:
    • ManifestHeader.Fingerprint
    • ManifestHeader.Checksum

    Сам расчёт сводится к последовательному вычислению хеша функцией Adler32 для всех указанных структур:

    Delphi
    function ManifestChecksum(Header: pCache_ManifestHeader; entries, names, hashs, table, MFP, UCF: pByte): uint32_t;
    var
      tmp1, tmp2: uint32;
    begin
      tmp1:=Header.Fingerprint;
      tmp2:=Header.Checksum;
      Header.Fingerprint:=0;
      Header.Checksum:=0;
      result:=adler32(0, pAnsiChar(Header), sizeof(TCache_ManifestHeader));
      result:=adler32(result, pAnsiChar(entries), sizeof(TCache_ManifestNode)*Header^.NodeCount);
      result:=adler32(result, pAnsiChar(names), Header^.NameSize);
      result:=adler32(result, pAnsiChar(hashs), sizeof(uint32)*Header^.HashTableKeyCount);
      result:=adler32(result, pAnsiChar(table), sizeof(uint32)*Header^.NodeCount);
      if Header^.NumOfMinimumFootprintFiles>0 then
        result:=adler32(result, pAnsiChar(MFP), sizeof(uint32)*Header^.NumOfMinimumFootprintFiles);
      if Header^.NumOfUserConfigFiles>0 then
        result:=adler32(result, pAnsiChar(UCF), sizeof(uint32)*Header^.NumOfUserConfigFiles);
      Header.Fingerprint:=tmp1;
      Header.Checksum:=tmp2;
    end;


    Заключение


    Остальные функции, не рассмотренные в данной статье ввиду громоздкости их описания (использование битовых карт занятых секторов при изменении карты секторов, перестроение данной карты и многое-многое другое) можно просмотреть в репозитории (там же лежат и остальные фрагменты программ, которые будут рассмотрены в последующих статьях). Данные исходные коды можно использовать в своих проектах (если кому-то нужны такие раритеты...).
    Примерная дата последнего обновления всех исходных кодов — вторая половина 2011-ого года.

    PS: Написание данной библиотеки мне очень помогло при написании лабораторной работы по предмету Операционные системы в университете — требовалось симулировать работу файловой системы (создание, запись, чтение и удаление файлов). Моя работа была первой и, наверное, единственной за всё время, в которой использовался именно образ файловой системы с разбиением на блоки и сектора — а это была просто-напросто урезанная версия данной бибилотеки (без контрольных сумм). Даже дефрагментатор для кеша я дописал в составе данной работы…
    Метки:
    Поделиться публикацией
    Комментарии 31
    • +2
      Жаль, что GCF больше не используется. Игры теперь удаляются по 10 минут.
      • +15
        Anti-Steam сообщество

        Лучше бы anti-origin сообщество сделали.
        • +4
          Тогда Origin еще только в проект был. Да и в этом я сомневаюсь…
          Всё-таки orogin появился в 2011-ом году, а стим — в 2003-ем.
          • +2
            Из anti-origin знаю только Outcome. Видимо, сабж никому не интересен.
            • +3
              Скорее всего да — список игр, распространяемых через него, значительно меньше…
              • +8
                Но боли в процессе использования в 2 раза больше. Я бы предпочёл все игры покупать только в стиме, а Origin выпилил бы с радостью, но сделать это мешают 2-3 игры (:
                • +3
                  И все Battlefield? )))
                  • 0
                    В точку =) А так же Titanfall и Need for Speed: Rivals.
                    Кстати Battlefield — явление сезонное. Я например удалил третью часть с компьютера, сразу же как купил 4-ю.
                    • +2
                      Чем больше выходит новых баттлфилдов, тем сильнее мне нравится старый добрый Bad Company 2 (и предыдущие).
                      • +1
                        Нет ничего лучше BF2 (сервера которой, кстати, до сих пор заполнены до упора)
                        • 0
                          Официальные сервера?
                  • 0
                    В Origin скачивается обычный установщик. Там ничего зашифровано нет. Я уже много раз скачивал архивы и бэкапил их на свой внешний диск.

                    p.s.
                    мой же опыт наоборот говорит о глюкавости Steam и без проблемном использовании Origin.
                    • +1
                      Вот бы ещё игры не требовали его для запуска.
                      • +1
                        Кроме тех кто на него завязан жестко, аля Battlefield не требуют Origin.
                        • +1
                          С чего вы это взяли? Главный exe-шник bf4 запускает origin, если тут не запущен. Да чего уж там — ники из origin используются в battlelog, а без него игры считай нет.
                          • 0
                            Вы точно прочитали то что я написал?
                            | Кроме тех кто на него завязан жестко, аля Battlefield
                            Т.е. Battlefield как раз жестко завязан на Origin, а Mass Effect, например — нет.
                            • 0
                              Да, пардон, был невнимателен.

                              Насчёт Mass Effect — я бы не был так оптимистичен. Её слабая привязка к ориджину, скорее следствие молодости ориджина относительно самой игры. Всё-таки ME3 делали в спешке и активно использовали уже имеющиеся наработки. А EA перестала издаваться в стиме именно на экономической почве — слишком мало торговых возможностей.

                              Поэтому, будьте уверенны — все новые игры будут прибиты гвоздями к ориджину, поскольку EA прибыль ни за что не упустит.
                      • 0
                        В Steam можно тоже бекап сделать через меню и залить на диск.
                        • 0
                          Бэкап не сохраняет кеш, он потребуется, я делал в своё время.
                          А Origin предоставляет полноценный установщик.
                          • 0
                            Ну, может быть, сломали что-то. Но по идее Steam должен сохранять полностью обновлённый кеш.
              • +10
                Нда. Русские хакеры, делающие свою хакерскую работу в рамках вузовских курсовых.
                • +2
                  Это была даже не курсовая, а простая лаба. Просто по времени так совпало, что библиотека на тот момент была более-менее жизнеспособной.
                  • +3
                    И ещё — на диплом я вынес софтварную часть своей системы «умный дом». Так что в рамках универа при верном подходе можно много проектов выполнить, в том числе и коммерческие.
                    • +1
                      Как раз коммерческие проекты как диплом чаще всего и делаются — тут всем всё понятно — студент экономит время, препод видит что это не Hello World на коленке, а серьёзная работа.
                      А вот действительно взлом чего-либо делать в рамках академических часов — думаю, на это многие посмотрят как на дикость.
                      • +4
                        Я же не афишировал преподавателю, что используемая библиотека — результат анализа другой программы :)
                  • +1
                    А с Valve конфликтов не было, особенно во времена, когда эти инструменты были актуальны и использовались по назначению?
                    • +2
                      Извиняюсь, не туда ответил…
                    • +2
                      Не было — я тогда шифровался как мог. Реальные контактные данные не светил нигде.
                      • +1
                        А сейчас уже не шифруешься? =)
                        • +3
                          GCF/NCF ушли в небытие, протоколы я смотрел только старые (на данный момент).
                          Из актуальной по сей день информации — форматы BLOB, CDR (ClientRegistry.blob), VDF (appinfo.vdf, packageinfo.vdf), да ещё обмен данными с мастер-сервером HL1 (может ещё что, но там уже мелочи, не проверял).
                          • 0
                            VDF вроде через tier1 KeyValues, код которого есть в SDK, нормально парсится, а обмен данных с мастер-сервером полностью задокументирован на VDC. Так что, расшифровывайся)

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