From 78fc5f1debf1395d5df0bab7cc0dde54351205cb Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Mon, 22 Jun 2026 01:58:51 +0400 Subject: docs: rewrite MkDocs documentation --- docs/tomes/03-resources.md | 561 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 561 insertions(+) create mode 100644 docs/tomes/03-resources.md (limited to 'docs/tomes/03-resources.md') diff --git a/docs/tomes/03-resources.md b/docs/tomes/03-resources.md new file mode 100644 index 0000000..acf97a8 --- /dev/null +++ b/docs/tomes/03-resources.md @@ -0,0 +1,561 @@ +# III. Ресурсная система и форматы + +Ресурсная система Iron3D переводит имена из миссий и прототипов в объекты, +которыми пользуются подсистемы мира, рендера, анимации, звука, эффектов и +управления. В этом пути участвуют несколько разных сущностей: файл на диске, +открытый архив, запись каталога, подготовленный payload и готовый runtime-объект. +Их нельзя смешивать, потому что у каждого уровня свой срок жизни, свои правила +кэширования и свой набор проверок. + +Основной контейнер ресурсов -- [NRes](../reference/nres.md). Он используется как +внешний архив (`objects.rlb`, `Material.lib`, `Textures.lib`) и как внутренний +контейнер модели `*.msh`. Второй библиотечный формат -- [RsLi](../reference/rsli.md): +его каталог находится в начале файла, а payload может храниться raw, через +потоковое преобразование, LZSS, адаптивный Huffman + LZSS или raw Deflate. +Визуальная часть прототипа дальше проходит через [MSH](../reference/msh.md), +[WEAR/MAT0](../reference/materials.md) и [Texm](../reference/texm.md), но этот +том описывает именно ресурсный слой: как найти, проверить, раскрыть и сохранить +данные до передачи их предметным подсистемам. + +```text +TMA или unit DAT + -> логический ключ + -> objects.rlb + -> archive.rlb :: model.msh + -> model.wea + -> Material.lib :: MAT0 + -> Textures.lib / LightMap.lib :: Texm +``` + +На демо-корпусе эта цепочка проверена целиком для всех реально размещённых +объектов. При этом полная таблица прототипов может содержать ссылки на контент, +которого нет в урезанной поставке. Диагностика должна различать недостижимую +ссылку в общем реестре и ресурс, реально требуемый выбранной миссией. + +## Ресурсный конвейер + +Загрузка ресурса состоит из последовательных стадий: + +1. Разрешить относительный путь с учётом глобального resource path и текущего + каталога игры. +2. Открыть архив или вернуть уже открытый archive object из кэша. +3. Найти запись каталога по имени, не меняя исходный порядок каталога. +4. Проверить bounds, размер payload и способ хранения. +5. Подготовить bytes: распаковать, применить потоковое преобразование или + вернуть raw-диапазон. +6. Разобрать предметный формат и создать объект подсистемы. +7. Сохранить готовый объект в отдельном кэше, если формат допускает повторное + использование. + +Эти стадии дают четыре независимых уровня кэша: + +1. Открытые архивы. +2. Каталоги имён, offsets и размеров. +3. Подготовленные блоки данных. +4. Кэши моделей, материалов, текстур, lightmaps, эффектов и служебных объектов. + +Повторное открытие того же нормализованного пути возвращает существующий +archive object и увеличивает счётчик владельцев. Готовая texture или model при +этом может жить дольше file handle и иметь собственную политику удаления. Кэш +предметного объекта не должен напрямую закрывать архив: он зависит от данных, +но не владеет файлом как ресурсом операционной системы. + +## Имена и пути + +Большинство игровых имён сравнивается без учёта регистра в ASCII-диапазоне. Это +не Unicode case folding. Для совместимости достаточно нормализовать `A..Z` в +`a..z`, а для RsLi-поиска -- переводить запрос в uppercase ASCII и укладывать его +в фиксированный ключ. + +Фиксированные строки читаются bounded parser-ом: строковая часть заканчивается +на первом NUL, но оставшийся хвост поля сохраняется. Нельзя очищать хвосты, +пересобирать регистр, заменять смешанные разделители или заранее переводить все +пути в абсолютные имена. Старые данные используют исторические имена библиотек, +разный регистр исходных путей и фиксированные поля, где после терминатора могут +оставаться значимые для roundtrip bytes. + +## Строгий и совместимый режимы + +Строгий reader нужен тестам, редактору и проверке корпуса. Он валидирует +структуру до выдачи любого `EntryView`: magic, версию, счётчики, арифметические +переполнения, bounds, sort permutation, alignment и точное завершение payload. +Если формат требует NUL-терминатор, строгий режим проверяет его именно в пределах +фиксированного поля. + +Совместимый reader повторяет только известные особенности оригинала: + +- линейный поиск при повреждённой сортировочной таблице; +- RsLi-исключение `deflate_eof_plus_one` для `sprites.lib::INTERF8.TEX`; +- material fallbacks, подтверждённые ресурсной цепочкой; +- отсутствие геометрии у системных и солнечных объектов, где mesh pass не + требуется. + +Режим совместимости не должен скрывать произвольные ошибки. Каждое послабление +оформляется как именованное правило и покрывается отдельным тестом. Если quirk +применим только к Deflate-записи, он не распространяется на LZSS, Huffman или +raw-диапазоны. + +## NRes + +`NRes` хранит произвольные именованные payload и их атрибуты. Каталог расположен +в конце файла, поэтому начало каталога вычисляется из полного размера файла и +числа записей. + +```text +[Header: 16 байт] +[Data region: payload с выравниванием] +[Directory: entry_count x 64 байта] +``` + +Все числа little-endian. + +```c +struct NResHeader16 { + char magic[4]; // "NRes" + uint32_t version; // 0x00000100 + int32_t entry_count; // >= 0 + uint32_t total_size; // равен фактическому размеру файла +}; +``` + +Производные значения: + +```text +directory_size = entry_count * 64 +directory_offset = total_size - directory_size +``` + +Reader проверяет, что `directory_offset >= 16`, умножение не переполнено, а +каталог заканчивается точно на `total_size`. + +### Запись каталога NRes + +```c +#pragma pack(push, 1) +struct NResEntry64 { + uint32_t type_id; // +0x00 + uint32_t attr1; // +0x04 + uint32_t attr2; // +0x08 + uint32_t size; // +0x0C + uint32_t attr3; // +0x10 + char name[36]; // +0x14 + uint32_t data_offset; // +0x38 + uint32_t sort_index; // +0x3C +}; +#pragma pack(pop) +``` + +Имя содержит не более 35 полезных байт и завершающий ноль. Writer запрещает +внутренний NUL и слишком длинное имя, но сохраняет неизвестные атрибуты +`attr1`, `attr2`, `attr3` без нормализации. Их смысл зависит от конкретного +типа ресурса и не может быть выведен из контейнера. + +Поле `sort_index` задаёт отображение из позиции в отсортированном списке в +исходный индекс записи. Каталог остаётся в исходном порядке. Поиск идёт по +отсортированному отображению, но возвращает исходную запись. При сохранении +writer строит массив исходных индексов, сортирует его по ASCII-case-insensitive +именам и записывает результат в `sort_index`. Если отображение нельзя использовать +или оно не является перестановкой в строгом режиме, совместимый путь переходит к +последовательному сравнению имён. + +### Размещение данных NRes + +Каждый active payload должен лежать после 16-байтового заголовка и полностью до +начала каталога. Канонические игровые файлы выравнивают начало следующего +payload до границы 8 байт нулевым заполнением. + +Порядок canonical save: + +1. Записать временный заголовок. +2. Записать payload всех записей в текущем порядке. +3. После каждого блока добавить нули до кратности 8. +4. Построить таблицу поиска имён. +5. Дописать каталог. +6. Записать окончательный `total_size`. + +Строгий reader выполняет проверки до выдачи записи: + +- `magic == "NRes"` и `version == 0x100`; +- `entry_count >= 0`, а `entry_count * 64` вычисляется без переполнения; +- `total_size` равен фактической длине файла; +- `directory_offset = total_size - entry_count * 64` не меньше 16; +- для каждой записи `data_offset >= 16` и `data_offset + size <= directory_offset`; +- поле имени содержит NUL в пределах 36 байт; +- каждый `sort_index < entry_count`; +- в строгом режиме все `sort_index` образуют перестановку `0..N-1`. + +Нулевое заполнение до границы 8 байт -- подтверждённое поведение игровых +архивов и canonical writer-а. Reader не должен считать ненулевой gap частью +соседнего payload, но lossless-редактор сохраняет исходные bytes, если файл +открыт не в режиме канонической пересборки. + +### Неплотная data region + +Проверка 120 NRes-файлов / 6 804 entries Части 1 и 134 файлов / 8 171 entries +Части 2 не выявила нарушений magic, version, total size, bounds, sort +permutation, ASCII-order, 8-byte alignment или перекрытий активных payload. +Однако `Textures.lib` Части 2 содержит большой ненулевой диапазон в data region, +который не адресуется ни одной записью каталога. Первый активный payload +начинается значительно позже начала файла, а каталог и все активные entries +остаются корректными. + +Следовательно, parser не должен требовать плотного покрытия data region. Нужно +различать три вида диапазонов: + +- `active payload` -- bytes, на которые указывает запись каталога; +- `gap/padding` -- bytes между активными диапазонами; +- `unindexed preserved region` -- произвольные bytes, не принадлежащие ни одной + записи. + +Canonical compact writer может исключить unindexed region только при явной +операции repack. Lossless editor сохраняет её побайтно вместе с исходным +порядком entries и gaps. + +## RsLi + +`RsLi` -- библиотечный архив с каталогом в начале файла. Записи могут храниться +в исходном виде или проходить один из поддержанных путей подготовки. + +```text +[Header: 32 байта] +[Entry table: entry_count x 32 байта] +[Payloads] +[необязательный trailer] +``` + +Заголовок начинается с двух байт `NL`. Версия равна `1`, число записей хранится +как знаковое 16-битное значение. Поле по смещению `0x0E` может содержать +`0xABBA`: это означает, что отображение сортировки уже подготовлено. + +Подтверждённые поля header: + +```text ++0x00 char[2] "NL" ++0x02 u8 reserved, в корпусе 0 ++0x03 u8 version, в корпусе 1 ++0x04 i16 entry_count ++0x0E u16 presorted_flag, значение 0xABBA ++0x14 u32 xor_seed +``` + +Остальные bytes заголовка сохраняются без нормализации. + +### Запись каталога RsLi + +После подготовки таблицы каждая запись имеет layout 32 байта: + +```c +struct RsLiEntry32 { + char name[12]; + uint8_t service[4]; + int16_t flags; + int16_t sort_to_original; + uint32_t unpacked_size; + uint32_t data_offset_raw; + uint32_t packed_size; +}; +``` + +Имя обычно хранится в uppercase ASCII. Четыре служебных байта после имени +сохраняются без изменения. `sort_to_original` играет ту же роль, что и +`sort_index` в NRes: связывает отсортированную позицию с исходной записью. + +Таблица на диске проходит обратимое побайтовое преобразование. Начальное +состояние берётся из младших 16 бит `xor_seed`. Если обозначить два байта +состояния как `lo` и `hi`, для каждого входного байта выполняется: + +```text +lo = hi XOR ((lo << 1) mod 256) +out = in XOR lo +hi = lo XOR (hi >> 1) +``` + +Операция симметрична: один и тот же цикл используется для подготовки и +восстановления. Состояние непрерывно проходит по всей таблице; его нельзя +перезапускать на каждой записи. + +### Способы хранения RsLi + +Способ определяется выражением `flags & 0x1E0`: + +```text +0x000 исходный блок +0x020 только потоковое байтовое преобразование +0x040 LZSS +0x060 преобразование, затем LZSS +0x080 адаптивный Huffman, затем LZSS +0x0A0 преобразование, адаптивный Huffman и LZSS +0x100 raw Deflate без оболочки zlib +``` + +Reader обязан различать все значения, а неизвестную маску отклонять как +неподдерживаемую. После любого пути должно быть получено ровно `unpacked_size` +байт. Методы `0x080` и `0x0A0` подтверждены decoder-кодом и синтетическими +тестами, но живых payload этих веток в проверенных RsLi-файлах не найдено. + +Параметры LZSS: + +- размер кольцевого окна -- `4096`; +- начальное заполнение -- байт `0x20`; +- начальная позиция -- `0xFEE`; +- управляющие признаки читаются от младшего бита к старшему; +- двухбайтовая ссылка кодирует 12-битную позицию и длину `n + 3`; +- восстановленные bytes сразу записываются обратно в кольцевое окно. + +В конце файла может находиться шестибайтовый media overlay trailer: два символа +`AO` и 32-битное значение `overlay`. В таком режиме фактическая позиция блока +равна `data_offset_raw + overlay`. Reader сначала проверяет, что overlay не +выходит за размер отображённого файла, затем проверяет весь диапазон записи. + +### Поиск, кэш и проверки RsLi + +Запрос имени переводится в uppercase ASCII и укладывается в фиксированный ключ. +При признаке `0xABBA` используется сохранённое отображение сортировки. Если +признака нет, loader строит его после чтения каталога. Некорректный индекс +приводит к последовательному поиску. + +Файл открывается через memory mapping. Runtime-запись хранит указатель на +упакованный диапазон, размеры и необязательный указатель на подготовленные +данные. Первый обычный `load` создаёт буфер и сохраняет результат; повторный +возвращает его из кэша. Быстрый путь может вернуть указатель непосредственно в +mapped file только для исходного блока. + +Reader проверяет: + +- сигнатуру `NL`, служебный байт и версию; +- неотрицательное число записей; +- размещение всей таблицы в файле; +- что сохранённое отображение сортировки является перестановкой; +- что эффективный диапазон каждого блока не выходит за конец файла; +- что способ хранения известен; +- что после подготовки получено ровно `unpacked_size` байт. + +В demo-каталоге и полных каталогах обеих частей наблюдаются два RsLi-файла: + +```text +gamefont.rlb 2 entries, все 0x040 LZSS +sprites.lib 24 entries, все 0x100 raw Deflate +``` + +Последняя запись `sprites.lib::INTERF8.TEX` объявляет packed range, который +заканчивается на один байт после физического EOF. Совместимый путь читает на +один байт меньше; строгий путь регистрирует именованный quirk +`deflate_eof_plus_one`. Это исключение не распространяется на другие записи, +методы или произвольные выходы за конец файла. + +Writer, который редактирует существующий архив, сохраняет все служебные bytes +заголовка и записей. Выбор оптимального способа упаковки для новых файлов +является отдельной политикой и не должен менять уже существующие entries без +явного запроса. + +## Реестр объектов + +Имя объекта в миссии является логическим ключом. Связь этого ключа с файлами +модели, материалов и служебных данных хранится в `objects.rlb`, который сам +использует формат NRes. Имя записи каталога -- ключ прототипа. Payload записи +состоит из записей по 64 байта: + +```c +struct ObjectRef64 { + char archive_name[32]; + char resource_name[32]; +}; +``` + +Payload каждой записи `objects.rlb` обязан быть кратен 64 байтам. Это +проверяется до чтения первой ссылки. Оба поля читаются как строки до первого +NUL, но полный 32-байтовый блок сохраняется при редактировании без очистки +хвоста. + +Разрешение прототипа: + +1. Найти entry реестра по логическому ключу без учёта ASCII-регистра. +2. Прочитать все `ObjectRef64` в исходном порядке. +3. Если ссылка указывает обратно в `objects.rlb`, рекурсивно раскрыть указанный + родительский prototype. +4. Объединить effective references родителя с локальными references дочерней + записи, сохранив порядок и происхождение. +5. Выбрать первую существующую ссылку с расширением `.msh`, открыть указанный + архив и найти модель по имени. +6. Загружать `.bas` как отдельный служебный ресурс сооружения, а не как замену + MSH. +7. Если effective prototype не содержит MSH, считать объект негеометрическим, + если это допускает его назначение. + +Resolver обязан детектировать циклы наследования, ограничивать глубину и +кэшировать результат раскрытия. В обеих частях fortification-прототипы используют +явного родителя из `objects.rlb`: родитель предоставляет MSH/WEAR/CPT/NDP/CTL, +а дочерняя запись добавляет собственный BASE. Негеометрический объект не является +ошибкой сам по себе: системные и солнечные сущности могут участвовать в логике +или эффектах без mesh pass. + +Контракт реализации: + +- сохранять порядок ссылок внутри прототипа; +- не выводить имя модели из имени entry, если имеется явная ссылка; +- проверять существование указанного архива и ресурса независимо; +- отделять статус «негеометрический объект» от статуса «повреждённая ссылка»; +- кэшировать результат разрешения ключа, но инвалидировать его при замене архива; +- в diagnostic mode строить полный граф зависимостей и отмечать узлы, достижимые + из выбранной миссии. + +В demo-варианте `objects.rlb` содержит 590 прототипов. У 554 есть прямая ссылка +на MSH; 549 таких ссылок разрешаются в доступных demo-архивах. Ещё 34 прототипа +раскрываются через родительскую запись `objects.rlb` и дополняются локальным +BASE. Семь записей не дают геометрию, а 41 ссылка всего реестра указывает на +контент, которого нет в урезанной поставке. Для 501 запросов прототипов, +порождаемых шестью demo-миссиями, найдены прототип, MSH и WEAR. + +## Unit DAT + +Запись миссии может ссылаться не на один ключ, а на unit-файл `*.dat`. Такой файл +перечисляет компоненты сложного игрового объекта. + +```text +TMA object + -> путь к unit DAT + -> список component keys + -> несколько entries objects.rlb + -> модели, WEAR, control points, effects и другие ресурсы +``` + +Это объясняет, почему один размещённый unit может состоять из корпуса, башен, +оружия, эффектов и служебных частей. В демоверсии найдено 425 unit-файлов и +5 219 записей; все разобраны без ошибок. Наблюдаемый тип записи равен `1`, а +архив назначения -- `objects.rlb`. В 5 205 из 5 219 фиксированных полей имени +обнаружены ненулевые bytes после строкового терминатора; reader использует +строковую часть, а lossless writer сохраняет весь исходный блок. + +Размер каждого unit DAT удовлетворяет формуле: + +```text +file_size = 8 + record_count * 112 +``` + +Первые два байта header равны `F1 F0`. Оставшиеся шесть bytes имеют несколько +наблюдаемых вариантов; их семантика пока не названа и они сохраняются как +`header_opaque[6]`. + +```c +#pragma pack(push, 1) +struct UnitDatRecord112 { + char archive_name[32]; // +0x00 + char resource_name[32]; // +0x20 + uint32_t kind; // +0x40, в корпусе всегда 1 + int32_t parent_or_link; // +0x44 + char description[32]; // +0x48 + uint32_t tail0; // +0x68, opaque + uint32_t tail1; // +0x6C, opaque +}; +#pragma pack(pop) +``` + +Во всех проверенных records `archive_name == "objects.rlb"` и `kind == 1`. +Поле `parent_or_link` встречается как `-1`, `0`, `1` и другие небольшие индексы +и связывает компоненты составного unit; точная предметная классификация ссылки +ещё не закрыта. `description` -- человекочитаемое описание компонента. В Части 2 +есть поля `description[32]`, полностью заполненные без NUL; это валидная bounded +string длиной 32 байта. Требование обязательного terminator применяется только +к полям, где оно доказано форматом. `tail0` и `tail1` нельзя нормализовать. + +Проверено 425 файлов / 5 219 records Части 1 и 676 файлов / 8 145 records +Части 2. Все соответствуют формуле размера, `kind == 1` и +`archive_name == "objects.rlb"`. + +## Вспомогательные форматы + +MSH, материал и текстура отвечают за видимую форму. Полноценный прототип +дополнительно хранит точки крепления, зависимости, управляющие параметры, +области взаимодействия и ссылки на эффекты. Эти данные распределены между +несколькими небольшими форматами. + +Для них действует строгая граница знания: framing, counts и валидность корпуса +могут быть подтверждены parser-ом, тогда как предметный смысл части полей +остаётся неизвестным. Reader предоставляет typed view для доказанных полей и +raw bytes для остальных. Инструмент должен показывать статус поля: +`layout-confirmed`, `consumer-inferred` или `opaque`. + +### CTPT + +В demo-корпусе найдено 284 CTPT-ресурса и 3 599 точек; все прочитаны без ошибок. +Имена показывают назначение слоя: `TurretCenter`, `TurretDirect`, +`CameraCenter`, `TargetDirect`, `Root`, `Sfx_1`, `Sign_Entrance1`, `Width`, +`Height`, `Dir`. + +CTPT хранит локальные marker-точки модели. После применения transform такая точка +становится позицией или направлением в мире. Оружие может использовать её для +дула или оси башни, камера -- для привязки обзора, эффект -- для точки появления. +Конкретное назначение определяется именем и consumer-ом, а не одним общим флагом. +Первое 32-битное поле чаще равно `0`; встречаются `0x80000000` и редкий +вариант. До установления точной семантики оно хранится как `flags_raw`. + +### NDPR + +Проверено 494 NDPR-ресурса и 1 915 записей. Они ссылаются на `animals.rlb`, +`system.rlb`, `static.rlb`, `turrets.rlb`, `weapon.rlb` или используют пустое +имя архива. В 89 записях присутствует связанный эффект. Пустое имя архива +разрешается относительно текущего контекста. Reader хранит ссылку и остальные +параметры раздельно; writer сохраняет исходный порядок. + +### EXPL и reference arrays + +Проверено 144 ресурса EXPL: 26 используют версию 1, 54 -- версию 2, 64 -- +версию 3. Reader выбирает layout по version field и требует точного завершения +payload. Полная field-level семантика всех версий пока не доказана, поэтому +version-specific opaque sections сохраняются. + +Отдельная проверенная группа из 585 ресурсов содержит 2 956 однотипных +ссылочных records. Их границы и counts закрыты, однако единое предметное имя +всего семейства не подтверждено всеми consumers. В API безопаснее использовать +нейтральное `ReferenceArray` и конкретизировать назначение на уровне типа entry. + +### SUND и CTLD + +Два ресурса SUND содержат суммарно 12 ключей. Их следует загружать как параметры +системного объекта, а не как геометрию. + +Для CTLD проверено 531 payload. Размеры и сочетания счётчиков сильно различаются, +поэтому parser должен быть версионно- и счётчик-ориентированным, а неизвестные +секции -- храниться в исходном виде. + +### TRF, ANI и SKE + +В демоверсии обнаружены 5 файлов TRF, 38 preload-записей, 8 ANI-ресурсов и +6 SKE-ресурсов. Все проходят структурный разбор. Эти семейства участвуют в +подготовке компонентов и анимационных или управляющих данных до создания +runtime-объекта. + +Поскольку живой корпус невелик, редактор не должен синтезировать новые варианты +этих форматов по догадке. Безопасный режим -- читать доказанные счётчики и +ссылки, предоставлять raw-view неизвестных секций и обеспечивать побайтовое +сохранение неизменённых данных. + +### BASE + +Проверено 30 BASE-ресурсов; каждый содержит ровно один polygon record и проходит +структурную проверку. BASE payload и ссылка `.bas` в `objects.rlb` выполняют +связанные, но разные роли: + +- наличие ссылки `.bas` позволяет registry resolver-у искать одноимённый + `.msh` в том же архиве; +- сам BASE payload загружается отдельной подсистемой сооружений и не заменяет + MSH geometry. + +Resolver не должен интерпретировать bytes BASE как mesh. Writer сохраняет +polygon record и неизвестные поля 1:1, пока полный gameplay-контракт BASE не +подтверждён. + +## Правило сохранения + +Lossless editor сохраняет неизвестные поля, хвосты фиксированных строк, +служебные bytes, gaps, padding и unindexed regions. Writer пересчитывает только +явно производные значения: размеры, offsets, число записей, сортировочную +перестановку и padding. Такая дисциплина позволяет редактировать известную +часть ресурса, не разрушая данные, смысл которых пока не установлен. + +Canonical repack допустим только как явная операция. Он может исключать +неиндексируемые диапазоны, пересортировывать таблицы и пересобирать padding, но +не должен быть побочным эффектом обычного редактирования. Если пользователь +открыл существующий архив и изменил один известный атрибут, все остальные bytes, +не являющиеся производными от этого изменения, должны пройти roundtrip без +потери. -- cgit v1.2.3