Документация формата контейнера VFS (Virtual File System), используемого
Pathologic 2005 / The Void для упаковки игровых ассетов. Формат реализован в
VFS.dll (потребитель) и pkutil.exe (производитель); оба находятся в одном
дереве исходников 2005 года.
Источники:
- Чтение:
SRC/PlagueCity/Game/VFS/VFileSystem.cpp,VFSListing.h/.cpp - Запись:
SRC/PlagueCity/tools/pkutil/PakFile.cpp,PakDirectory.cpp,pkutil.cpp - Сборочная обвязка:
SRC/PlagueCity/make/pak.xml
Архивы, входящие в состав alpha-сборки (alpha/data/): Actors.vfs,
Fonts.vfs, Geometries.vfs, Scenes.vfs, Scripts.vfs, Sounds.vfs,
Strings.vfs, Textures.vfs, UI.vfs, World.vfs.
Архив .vfs — это плоский контейнер с тремя областями:
+--------------------+ 0
| 4-byte magic | "LP1C" (bytes 4C 50 31 43)
+--------------------+ 4
| Directory tree | recursive: root DIRECTORY block at offset 4
| (header region) |
+--------------------+ header_end
| File payload | raw file bytes, in the order chosen by the writer
| |
+--------------------+ EOF
Сжатие и шифрование отсутствуют. Пэйлоады хранятся вплотную друг к другу
без отступов; выравнивание неявное (поле m_ulOffset файла — это абсолютное
байтовое смещение в архиве). Формат читается как через stdio (fread), так и
через CreateFileMapping — последний требует настройки view-проекций с учётом
выравнивания (VFileSystem.cpp:154), но сами байты файла невыровненные.
Область заголовка устроена так, что её можно загружать последовательно от
смещения 4 до header_end без seek-ов. После разбора тот же файл архива
заново открывается через CreateFile + CreateFileMapping и используется как
отображённое в память хранилище для чтения отдельных файлов.
offset size value
0 2 0x504C // multi-char literal 'PL' → bytes 4C 50
2 2 0x4331 // multi-char literal 'C1' → bytes 31 43
На диске четыре байта — 4C 50 31 43 = ASCII "LP1C".
Производитель записывает магию как два значения unsigned short, буквально
названных 'PL' и 'C1' (PakFile.cpp:23); MSVC вычисляет каждую
multi-character константу как (c0 << 8) | c1, а little-endian запись
переставляет байты на диске. Читатель сверяет их аналогичным образом
(VFileSystem.cpp:27, pkutil.cpp:172).
"LP" + "C1" правдоподобно расшифровывается как «LifeStudio Pathologic
City, version 1» — учитывая корни проекта в LifeStudio HEAD SDK.
Начиная со смещения 4, архив содержит блок корневой директории. Блоки
директорий рекурсивно разворачиваются обходом в глубину pre-order: для каждой
директории сначала записываются все её файлы, затем последовательно идут блоки
её поддиректорий (в произвольном порядке, заданном map-сортировкой).
+-----------------------+
| ulDirCount : u32 | number of subdirectories in this dir
| ulFileCount : u32 | number of files in this dir
+-----------------------+
| FILE ENTRY x N | N = ulFileCount, see 3.2
+-----------------------+
| DIR ENTRY x M | M = ulDirCount, see 3.3
+-----------------------+
Чтение: CVFSListing::LoadDirectory (VFSListing.cpp:51).
Запись: CPakDirectory::_SaveHeader (PakDirectory.cpp:72).
+--------------------------+
| VARLEN string : name | see 3.4
+--------------------------+
| FILE_INFO (16 bytes): |
| m_ulSize : u32 | file size in bytes
| m_ulOffset : u32 | absolute offset into the archive
| m_ChTime : FILETIME | 8 bytes, Windows FILETIME (100-ns since 1601)
+--------------------------+
FILE_INFO объявлен в VFSListing.h:6. Он пишется/читается одним вызовом
fwrite/fread, так что его раскладка эквивалентна #pragma pack(4) (12
байт данных + отсутствие padding, поскольку все поля выровнены по 4 байтам,
включая 4-байтовую вторую половину FILETIME).
Порядок записи: файлы внутри директории сортируются по размеру по
возрастанию через multiset m_FilesOrdered, ключом которого является
FILE_INFO::m_ulSize (PakDirectory.h:15, PakDirectory.cpp:78). Маленькие
файлы пишутся первыми. Читатель не зависит от этого порядка — поиск файла
по имени идёт через hash map.
+--------------------------+
| VARLEN string : name | subdirectory name (see 3.4)
+--------------------------+
| DIRECTORY block | recursive, see 3.1
+--------------------------+
Отдельной структуры с информацией о директории нет: запись поддиректории — это просто имя, за которым сразу следует вложенный блок директории.
Итерация писателя по поддиректориям использует порядок вставки, заданный
map-сортировкой (PakDirectory.cpp:88), поэтому блоки поддиректорий
появляются в лексикографическом порядке имён без учёта регистра.
ASCII-строка с префиксом длины в 7 или 15 бит (без терминирующего NUL).
byte 0 :
bit 7 = 0 → length is the low 7 bits (1..127)
bit 7 = 1 → length is the low 7 bits + (byte 1 << 7), giving 0..32767
byte 1 (only if bit 7 of byte 0 was set):
high 8 bits of length
bytes [N..]:
N raw ASCII characters, no terminator
Запись: WriteString (PakDirectory.cpp:11).
Чтение: ReadString (VFSListing.cpp:15).
Строки длиннее 32767 байт отвергаются писателем. Читатель не проверяет это ограничение, но и не может сам сгенерировать такие длины.
Пустые имена файлов теоретически могут быть записаны (байт длины 0x00 +
ноль символов), но CVFSListing::LoadDirectory в debug-сборках сработает на
ASSERT(CheckName(...)). В release-сборках (а данная сборка именно такова)
ассерт — это no-op, и пустое имя молча оказывается в hash map.
IsValidVFSChar (VFSListing.h:94) ограничивает имена набором:
[a-zA-Z0-9_.#@]
Любой символ вне этого набора (включая пробелы, дефисы, скобки и — что важно —
/ или \) отвергается. Производитель (pkutil) проверяет это при
рекурсивном обходе директорий (pkutil.cpp:29, pkutil.cpp:50); команда c
завершает работу с ошибкой при некорректном имени, команда f («force»)
выдаёт предупреждение и продолжает. Проверка в читателе — это debug-only
ASSERT, так что вручную собранный архив с именами, не соответствующими
правилам, всё равно загрузится.
И поиск (CHashFunc, CNameCmp в VFSListing.h:13), и map в писателе
используют сравнение без учёта регистра (stricmp). Хеш — кастомная
двухсостоянийная mix-функция по символам, приведённым tolower:
size_t hash(string s) {
unsigned x1 = 0x14f2affc;
unsigned x2 = 0xfac5bd8f;
for (char c : s) {
x1 += 8*x2 + 0xe5fc82a9;
x2 += 8*x1 + tolower(c);
}
return x1 + x2;
}Это влияет только на поиск в памяти; на диске строки сохраняют исходный
регистр (писатель напрямую передаёт WIN32_FIND_DATA::cFileName).
После всего дерева заголовков идёт область пэйлоада файлов. Байты каждого
файла размещаются по абсолютному смещению, записанному в его
FILE_INFO::m_ulOffset. Производитель записывает их через
CPakDirectory::SaveData (PakDirectory.cpp:101) в том же порядке обхода в
глубину, что и для заголовка:
- Файлы каждой директории в порядке возрастания размера (совпадая с порядком в заголовке).
- Затем каждая поддиректория рекурсивно.
Это значит, что значения m_ulOffset монотонно возрастают вдоль обхода
заголовка, и между пэйлоадами нет промежутков. Размер заголовка вычислен
заранее в CPakDirectory::GetHeaderOffset (PakDirectory.cpp:60) так, чтобы
текущее смещение писателя (ulHeaderOffset + 4, PakFile.cpp:16) точно
совпало с позицией пэйлоада первого файла.
pkutil завершает работу с ошибкой, если размер исходного файла изменился
между сканированием и записью данных (PakDirectory.cpp:110).
CreateFileSystem(path) (VFS.cpp:31) создаёт CVFileSystem для
<path>.vfs. Конструктор (VFileSystem.cpp:10):
- Открывает
<path>.vfsчерез_fsopen(... "rb", _SH_DENYWR). - Проверяет 4-байтовую магию.
- Строит дерево директорий в памяти через
CVFSListing. - Закрывает FILE* и снова открывает тот же путь через
CreateFile+CreateFileMapping(PAGE_READONLY)для последующих memory-mapped чтений.
При открытии файла по VFS-пути pszName читатель сначала пытается выполнить
запрос вне архива (VFileSystem.cpp:86, :118):
fopen("<archive-path>\\" + pszName, ...) // try loose file alongside archiveЕсли loose-файл по этому пути существует на диске, используется он, а запись в
архиве игнорируется. Это механизм, позволяющий разработчикам/моддерам
горячо патчить отдельные ассеты без пересборки .vfs. Только если
loose-файл отсутствует, читатель откатывается на листинг архива.
Файлы с m_ulSize ≤ 16 * 1024 байт (c_ulSmallFileSize, VFileSystem.h:13)
могут быть продвинуты в in-memory CFSCache при первом обращении
(VFileSystem.cpp:102). Более крупные файлы обслуживаются напрямую из
memory-mapped view архива (MapViewOfFile с правкой выравнивания под
allocation-granularity в :154).
CreateStoreObject, RenameFile и RemoveFile безусловно возвращают
NULL/false (VFileSystem.h:36, VFileSystem.cpp:161). VFS строго
read-only во время работы — модификации требуют повторного запуска pkutil.
Краткая справка по CLI (pkutil.cpp:195):
pkutil c <archive.vfs> <source_dir> [pattern] # create, strict name check
pkutil f <archive.vfs> <source_dir> [pattern] # create, force (warn on bad names)
pkutil l <archive.vfs> # list contents
<source_dir>сканируется рекурсивно. Каждая поддиректория становится поддиректорией VFS; каждый подходящий файл — записью файла в VFS.pattern(по умолчанию*.*) — Win32-шаблон, применяемый только к именам файлов; директории обходятся всегда, независимо от шаблона. Используется вpak.xmlдляVideo.vfs, чтобы включать только*.mot(pak.xml:127)..и..пропускаются (pkutil.cpp:48).- Режим
cзавершается ошибкой, если в любом имени есть символ вне[a-zA-Z0-9_.#@]. Режимfвыдаёт предупреждение и продолжает.
Оркестратор сборки ассетов (pak.xml) подключает по одному VFS на каждую
верхнеуровневую категорию ассетов и единственную цель all, собирающую
полный набор.
- Пустой файл:
m_ulSize == 0допустимо; пэйлоад вносит ноль байт в архив. Читатель возвращает корректный, но пустой memory-mapped view (VFileSystem.cpp:143). - Пустая директория:
ulDirCount == 0 && ulFileCount == 0— это валидный блок директории (8 байт всего). - Максимальная длина имени: 32767 байт (ограничение писателя). На
практике имена ассетов 2005 года остаются заметно ниже 128 байт, поэтому
второй байт длины (
>= 0x80) встречается редко. - Максимальный размер файла: 2³² − 1 байт на файл (
m_ulSize— этоu32). - Максимальный размер архива: 2³² − 1 байт всего (
m_ulOffset—u32). - Порядок байт: little-endian повсюду (host order на единственной целевой платформе — x86-32 Windows).
- Патологический случай — дубликаты имён в одной директории: писатель
отказывается добавить вторую запись с тем же именем (без учёта регистра,
PakDirectory.cpp:39,:48). Семантикаhash_mapчитателя такова, что дубликат в вручную собранном архиве сработает на ассерт в debug-сборке и молча перезапишет в release. - Порядок файлов внутри директории: по возрастанию размера в выводе писателя (детерминированно при том же входе). Поиск от этого не зависит.
Заголовок alpha/data/Scripts.vfs (первые 80 байт):
00000000: 4c50 3143 0000 0000 7501 0000 0f63 735f LP1C....u....cs_
00000010: 706c 6179 5f61 6c6c 2e62 696e 6500 0000 play_all.bine...
00000020: c833 0000 ceae f7bf 056e c401 1469 7465 .3.......n...ite
00000030: 6d5f 6275 7261 685f 7365 7275 6d2e 6269 m_burah_serum.bi
00000040: 6e74 0000 002d 3400 0004 7c45 c905 6ec4 nt...-4...|E..n.
Расшифровка:
| Offset | Bytes | Field | Value |
|---|---|---|---|
| 0x00 | 4C 50 31 43 |
magic | "LP1C" |
| 0x04 | 00 00 00 00 |
root ulDirCount |
0 |
| 0x08 | 75 01 00 00 |
root ulFileCount |
373 |
| 0x0C | 0F |
name length | 15 |
| 0x0D | 63 73 5F … 62 69 6E |
name | cs_play_all.bin |
| 0x1C | 65 00 00 00 |
m_ulSize |
101 |
| 0x20 | C8 33 00 00 |
m_ulOffset |
0x33C8 = 13256 |
| 0x24 | CE AE F7 BF 05 6E C4 01 |
m_ChTime |
FILETIME |
| 0x2C | 14 |
next name length | 20 |
| 0x2D | 69 74 65 6D … 2E 62 69 6E |
name | item_burah_serum.bin |
| 0x41 | 74 00 00 00 |
m_ulSize |
116 |
| … | … | … | … |
Количество файлов (373) совпадает с 373 скомпилированными скриптами, как
известно, лежащими в Scripts.vfs (см. pipeline.md). Первый
файл в списке cs_play_all.bin — самый маленький, что согласуется с
писателем, упорядочивающим записи по размеру.
- Интеграция со стороны движка-потребителя: интерфейс
IFileSystem, экспортируемый изVFS.dllкакCreateFileSystem(const char *pszPath). cargo/scripts.vfsиспользует тот же формат (можно проверить черезpkutil l).- Переопределение loose-файлами (§5.1) — стандартный способ тестировать модифицированные отдельные ассеты без пересборки архива.