Skip to content

Latest commit

 

History

History
357 lines (275 loc) · 19.7 KB

File metadata and controls

357 lines (275 loc) · 19.7 KB

Pathologic 2005 — формат архива .vfs

Документация формата контейнера 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.


1. Общая структура

Архив .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 и используется как отображённое в память хранилище для чтения отдельных файлов.


2. Магия

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.


3. Дерево директорий

Начиная со смещения 4, архив содержит блок корневой директории. Блоки директорий рекурсивно разворачиваются обходом в глубину pre-order: для каждой директории сначала записываются все её файлы, затем последовательно идут блоки её поддиректорий (в произвольном порядке, заданном map-сортировкой).

3.1 Блок директории

+-----------------------+
| 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).

3.2 Запись файла

+--------------------------+
| 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.

3.3 Запись директории

+--------------------------+
| VARLEN string  : name    |   subdirectory name (see 3.4)
+--------------------------+
| DIRECTORY block          |   recursive, see 3.1
+--------------------------+

Отдельной структуры с информацией о директории нет: запись поддиректории — это просто имя, за которым сразу следует вложенный блок директории.

Итерация писателя по поддиректориям использует порядок вставки, заданный map-сортировкой (PakDirectory.cpp:88), поэтому блоки поддиректорий появляются в лексикографическом порядке имён без учёта регистра.

3.4 Строка переменной длины

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.

3.5 Допустимые символы имени

IsValidVFSChar (VFSListing.h:94) ограничивает имена набором:

[a-zA-Z0-9_.#@]

Любой символ вне этого набора (включая пробелы, дефисы, скобки и — что важно — / или \) отвергается. Производитель (pkutil) проверяет это при рекурсивном обходе директорий (pkutil.cpp:29, pkutil.cpp:50); команда c завершает работу с ошибкой при некорректном имени, команда f («force») выдаёт предупреждение и продолжает. Проверка в читателе — это debug-only ASSERT, так что вручную собранный архив с именами, не соответствующими правилам, всё равно загрузится.

3.6 Сравнение имён и хеширование

И поиск (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).


4. Область пэйлоада

После всего дерева заголовков идёт область пэйлоада файлов. Байты каждого файла размещаются по абсолютному смещению, записанному в его FILE_INFO::m_ulOffset. Производитель записывает их через CPakDirectory::SaveData (PakDirectory.cpp:101) в том же порядке обхода в глубину, что и для заголовка:

  1. Файлы каждой директории в порядке возрастания размера (совпадая с порядком в заголовке).
  2. Затем каждая поддиректория рекурсивно.

Это значит, что значения m_ulOffset монотонно возрастают вдоль обхода заголовка, и между пэйлоадами нет промежутков. Размер заголовка вычислен заранее в CPakDirectory::GetHeaderOffset (PakDirectory.cpp:60) так, чтобы текущее смещение писателя (ulHeaderOffset + 4, PakFile.cpp:16) точно совпало с позицией пэйлоада первого файла.

pkutil завершает работу с ошибкой, если размер исходного файла изменился между сканированием и записью данных (PakDirectory.cpp:110).


5. Поведение читателя (VFS.dll / CVFileSystem)

CreateFileSystem(path) (VFS.cpp:31) создаёт CVFileSystem для <path>.vfs. Конструктор (VFileSystem.cpp:10):

  1. Открывает <path>.vfs через _fsopen(... "rb", _SH_DENYWR).
  2. Проверяет 4-байтовую магию.
  3. Строит дерево директорий в памяти через CVFSListing.
  4. Закрывает FILE* и снова открывает тот же путь через CreateFile + CreateFileMapping(PAGE_READONLY) для последующих memory-mapped чтений.

5.1 Приоритет переопределения (loose-файлы побеждают)

При открытии файла по VFS-пути pszName читатель сначала пытается выполнить запрос вне архива (VFileSystem.cpp:86, :118):

fopen("<archive-path>\\" + pszName, ...)   // try loose file alongside archive

Если loose-файл по этому пути существует на диске, используется он, а запись в архиве игнорируется. Это механизм, позволяющий разработчикам/моддерам горячо патчить отдельные ассеты без пересборки .vfs. Только если loose-файл отсутствует, читатель откатывается на листинг архива.

5.2 Кэш мелких файлов

Файлы с m_ulSize ≤ 16 * 1024 байт (c_ulSmallFileSize, VFileSystem.h:13) могут быть продвинуты в in-memory CFSCache при первом обращении (VFileSystem.cpp:102). Более крупные файлы обслуживаются напрямую из memory-mapped view архива (MapViewOfFile с правкой выравнивания под allocation-granularity в :154).

5.3 Операции записи отключены

CreateStoreObject, RenameFile и RemoveFile безусловно возвращают NULL/false (VFileSystem.h:36, VFileSystem.cpp:161). VFS строго read-only во время работы — модификации требуют повторного запуска pkutil.


6. Сборка VFS — pkutil.exe

Краткая справка по 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, собирающую полный набор.


7. Инварианты формата и пограничные случаи

  • Пустой файл: 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_ulOffsetu32).
  • Порядок байт: little-endian повсюду (host order на единственной целевой платформе — x86-32 Windows).
  • Патологический случай — дубликаты имён в одной директории: писатель отказывается добавить вторую запись с тем же именем (без учёта регистра, PakDirectory.cpp:39, :48). Семантика hash_map читателя такова, что дубликат в вручную собранном архиве сработает на ассерт в debug-сборке и молча перезапишет в release.
  • Порядок файлов внутри директории: по возрастанию размера в выводе писателя (детерминированно при том же входе). Поиск от этого не зависит.

8. Эмпирическая проверка

Заголовок 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 — самый маленький, что согласуется с писателем, упорядочивающим записи по размеру.


9. Перекрёстные ссылки

  • Интеграция со стороны движка-потребителя: интерфейс IFileSystem, экспортируемый из VFS.dll как CreateFileSystem(const char *pszPath).
  • cargo/scripts.vfs использует тот же формат (можно проверить через pkutil l).
  • Переопределение loose-файлами (§5.1) — стандартный способ тестировать модифицированные отдельные ассеты без пересборки архива.