Files
fparkan/docs/tomes/03-resources.md
T
Valentin Popov 78fc5f1deb
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
Test / Lint (push) Failing after 1m7s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
docs: rewrite MkDocs documentation
2026-06-22 01:58:51 +04:00

36 KiB
Raw Blame History

III. Ресурсная система и форматы

Ресурсная система Iron3D переводит имена из миссий и прототипов в объекты, которыми пользуются подсистемы мира, рендера, анимации, звука, эффектов и управления. В этом пути участвуют несколько разных сущностей: файл на диске, открытый архив, запись каталога, подготовленный payload и готовый runtime-объект. Их нельзя смешивать, потому что у каждого уровня свой срок жизни, свои правила кэширования и свой набор проверок.

Основной контейнер ресурсов -- NRes. Он используется как внешний архив (objects.rlb, Material.lib, Textures.lib) и как внутренний контейнер модели *.msh. Второй библиотечный формат -- RsLi: его каталог находится в начале файла, а payload может храниться raw, через потоковое преобразование, LZSS, адаптивный Huffman + LZSS или raw Deflate. Визуальная часть прототипа дальше проходит через MSH, WEAR/MAT0 и Texm, но этот том описывает именно ресурсный слой: как найти, проверить, раскрыть и сохранить данные до передачи их предметным подсистемам.

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 и их атрибуты. Каталог расположен в конце файла, поэтому начало каталога вычисляется из полного размера файла и числа записей.

[Header: 16 байт]
[Data region: payload с выравниванием]
[Directory: entry_count x 64 байта]

Все числа little-endian.

struct NResHeader16 {
    char     magic[4];      // "NRes"
    uint32_t version;       // 0x00000100
    int32_t  entry_count;   // >= 0
    uint32_t total_size;    // равен фактическому размеру файла
};

Производные значения:

directory_size   = entry_count * 64
directory_offset = total_size - directory_size

Reader проверяет, что directory_offset >= 16, умножение не переполнено, а каталог заканчивается точно на total_size.

Запись каталога NRes

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

[Header: 32 байта]
[Entry table: entry_count x 32 байта]
[Payloads]
[необязательный trailer]

Заголовок начинается с двух байт NL. Версия равна 1, число записей хранится как знаковое 16-битное значение. Поле по смещению 0x0E может содержать 0xABBA: это означает, что отображение сортировки уже подготовлено.

Подтверждённые поля header:

+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 байта:

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, для каждого входного байта выполняется:

lo  = hi XOR ((lo << 1) mod 256)
out = in XOR lo
hi  = lo XOR (hi >> 1)

Операция симметрична: один и тот же цикл используется для подготовки и восстановления. Состояние непрерывно проходит по всей таблице; его нельзя перезапускать на каждой записи.

Способы хранения RsLi

Способ определяется выражением flags & 0x1E0:

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-файла:

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 байта:

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. Такой файл перечисляет компоненты сложного игрового объекта.

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 удовлетворяет формуле:

file_size = 8 + record_count * 112

Первые два байта header равны F1 F0. Оставшиеся шесть bytes имеют несколько наблюдаемых вариантов; их семантика пока не названа и они сохраняются как header_opaque[6].

#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-у искать одноимённый <stem>.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 без потери.