aboutsummaryrefslogtreecommitdiff
path: root/docs/tomes/03-resources.md
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-22 00:58:51 +0300
committerValentin Popov <valentin@popov.link>2026-06-22 00:58:51 +0300
commit78fc5f1debf1395d5df0bab7cc0dde54351205cb (patch)
treeef8f7c72a183723fcbea0b2d1fefd7c28ca7bc18 /docs/tomes/03-resources.md
parent50c2cf4686b53ebd2b76318223096660e92305a4 (diff)
downloadfparkan-78fc5f1debf1395d5df0bab7cc0dde54351205cb.tar.xz
fparkan-78fc5f1debf1395d5df0bab7cc0dde54351205cb.zip
docs: rewrite MkDocs documentation
Diffstat (limited to 'docs/tomes/03-resources.md')
-rw-r--r--docs/tomes/03-resources.md561
1 files changed, 561 insertions, 0 deletions
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-у искать одноимённый
+ `<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 без
+потери.