![Steam Logo](http://habrastorage.org/getpro/habr/post_images/000/dca/198/000dca198a459957c5d705d59a8bc514.png)
Как и обещал в предыдущей статье, начинаю публиковать статьи о той части инфраструктуры 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». Данный путь самый очевидный, но очень медленный — происходит побайтовой сравнение строк, да ещё и обход по дереву. Сплошные затраты.
Для ускорения данной процедуры в заголовках есть хеш-таблицы.
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, о которой ещё будет разговор.
Получение полного пути к элементу
Данное действие несколько обратно предыдущему — по номеру элемента надо пройтись по дереву до корневого элемента и получить путь к файлу.
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).
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Кб), рассчитываю для них контрольные суммы и сверяю их со значениями из таблицы в заголовках.
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.
Дешифрование содержимого файлов
Поскольку многие игры незадолго до выхода можно загрузить заранее, то их содержимое в таких случаях оказывается полностью или частично зашифровано. С выходом игры становится доступен ключ для дешифровки данного контента, осуществляемая следующим кодом:
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: Написание данной библиотеки мне очень помогло при написании лабораторной работы по предмету Операционные системы в университете — требовалось симулировать работу файловой системы (создание, запись, чтение и удаление файлов). Моя работа была первой и, наверное, единственной за всё время, в которой использовался именно образ файловой системы с разбиением на блоки и сектора — а это была просто-напросто урезанная версия данной бибилотеки (без контрольных сумм). Даже дефрагментатор для кеша я дописал в составе данной работы…
ссылка на оригинал статьи http://habrahabr.ru/post/224027/
Добавить комментарий