aboutsummaryrefslogtreecommitdiff
path: root/docs/specs/nres.md
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-02-19 10:07:04 +0300
committerValentin Popov <valentin@popov.link>2026-02-19 10:07:04 +0300
commit0d7ae6a017b8b2bf26c5c14c39cb62b599e8262d (patch)
tree6398ba4a13d22656af75395db6de2e4f84d6c875 /docs/specs/nres.md
parenta281ffa32ea615670d369503692f057b2dc60e6f (diff)
downloadfparkan-0d7ae6a017b8b2bf26c5c14c39cb62b599e8262d.tar.xz
fparkan-0d7ae6a017b8b2bf26c5c14c39cb62b599e8262d.zip
Документирование и обновление спецификаций
- Обновлены спецификации `runtime-pipeline`, `sound`, `terrain-map-loading`, `texture`, `ui` и `wear`. - Добавлены разделы о статусе покрытия и оставшихся задачах для достижения 100% завершенности. - Внесены уточнения по архитектурным ролям, минимальным контрактам и требованиям к toolchain для каждой подсистемы. - Уточнены форматы данных и правила взаимодействия между компонентами системы.
Diffstat (limited to 'docs/specs/nres.md')
-rw-r--r--docs/specs/nres.md777
1 files changed, 124 insertions, 653 deletions
diff --git a/docs/specs/nres.md b/docs/specs/nres.md
index 32ccb1b..03b4c3e 100644
--- a/docs/specs/nres.md
+++ b/docs/specs/nres.md
@@ -1,718 +1,189 @@
-# Форматы игровых ресурсов
+# NRes
-## Обзор
+`NRes` — базовый контейнер ресурсов движка Parkan: Iron Strategy.
+Страница фиксирует формат на диске и runtime-контракт чтения/поиска/сохранения в высокоуровневом виде, без привязки к внутренним адресам и именам из дизассемблера.
-Библиотека `Ngi32.dll` реализует два различных формата архивов ресурсов:
+Связанная страница:
-1. **NRes** — основной формат архива ресурсов, используемый через API `niOpenResFile` / `niCreateResFile`. Каталог файлов расположен в **конце** файла. Поддерживает создание, редактирование, добавление и удаление записей.
+- [RsLi](rsli.md)
-2. **RsLi** — формат библиотеки ресурсов, используемый через API `rsOpenLib` / `rsLoad`. Таблица записей расположена **в начале** файла (сразу после заголовка) и зашифрована XOR-шифром. Поддерживает несколько методов сжатия. Только чтение.
+## 1. Назначение
----
+`NRes` используется как универсальный архив:
-## Часть 1. Формат NRes
+- 3D-модели (`*.msh`, `*.rlb`);
+- текстуры (`Texm`);
+- материалы (`MAT0`);
+- эффекты (`FXID`);
+- миссионные и служебные ресурсы.
-### 1.1. Общая структура файла
+Формат поддерживает:
-```
-┌──────────────────────────┐ Смещение 0
-│ Заголовок (16 байт) │
-├──────────────────────────┤ Смещение 16
-│ │
-│ Данные ресурсов │
-│ (выровнены по 8 байт) │
-│ │
-├──────────────────────────┤ Смещение = total_size - entry_count × 64
-│ Каталог записей │
-│ (entry_count × 64 байт) │
-└──────────────────────────┘ Смещение = total_size
-```
-
-### 1.2. Заголовок файла (16 байт)
-
-| Смещение | Размер | Тип | Значение | Описание |
-| -------- | ------ | ------- | ------------------- | ------------------------------------ |
-| 0 | 4 | char[4] | `NRes` (0x4E526573) | Магическая сигнатура (little-endian) |
-| 4 | 4 | uint32 | `0x00000100` (256) | Версия формата (1.0) |
-| 8 | 4 | int32 | — | Количество записей в каталоге |
-| 12 | 4 | int32 | — | Полный размер файла в байтах |
-
-**Валидация при открытии:** магическая сигнатура и версия должны совпадать точно. Поле `total_size` (смещение 12) **проверяется на равенство** с фактическим размером файла (`GetFileSize`). Если значения не совпадают — файл отклоняется.
+- чтение;
+- поиск по имени;
+- редактирование (add/replace/remove);
+- полную пересборку архива.
-### 1.3. Положение каталога в файле
+## 2. Общий layout файла
-Каталог располагается в самом конце файла. Его смещение вычисляется по формуле:
-
-```
-directory_offset = total_size - entry_count × 64
+```text
+[Header: 16]
+[Data region: variable, 8-byte aligned chunks]
+[Directory: entry_count * 64, всегда в конце файла]
```
-Данные ресурсов занимают пространство между заголовком (16 байт) и каталогом.
-
-### 1.4. Запись каталога (64 байта)
-
-Каждая запись каталога занимает ровно **64 байта** (0x40):
-
-| Смещение | Размер | Тип | Описание |
-| -------- | ------ | -------- | ------------------------------------------------- |
-| 0 | 4 | uint32 | Тип / идентификатор ресурса |
-| 4 | 4 | uint32 | Атрибут 1 (например, формат, дата, категория) |
-| 8 | 4 | uint32 | Атрибут 2 (например, подтип, метка времени) |
-| 12 | 4 | uint32 | Размер данных ресурса в байтах |
-| 16 | 4 | uint32 | Атрибут 3 (дополнительный параметр) |
-| 20 | 36 | char[36] | Имя файла (null-terminated, макс. 35 символов) |
-| 56 | 4 | uint32 | Смещение данных от начала файла |
-| 60 | 4 | uint32 | Индекс сортировки (для двоичного поиска по имени) |
+Критично: каталог всегда расположен в конце файла.
-#### Поле «Имя файла» (смещение 20, 36 байт)
+## 3. Заголовок (16 байт)
-- Максимальная длина имени: **35 символов** + 1 байт null-терминатор.
-- При записи поле сначала обнуляется (`memset(0, 36 байт)`), затем копируется имя (`strncpy`, макс. 35 символов).
-- Поиск по имени выполняется **без учёта регистра** (`_strcmpi`).
+Все значения little-endian.
-#### Поле «Индекс сортировки» (смещение 60)
+| Offset | Size | Type | Значение |
+|---:|---:|---|---|
+| 0 | 4 | char[4] | `NRes` |
+| 4 | 4 | u32 | `0x00000100` (версия 1.0) |
+| 8 | 4 | i32 | `entry_count` (должен быть `>= 0`) |
+| 12 | 4 | u32 | `total_size` (должен быть равен фактическому размеру файла) |
-Используется для **двоичного поиска по имени**. Содержит индекс оригинальной записи, отсортированной в алфавитном порядке (регистронезависимо). Индекс строится при сохранении файла функцией `sub_10013260` с помощью **пузырьковой сортировки** по именам.
+Производные значения:
-**Алгоритм поиска** (`sub_10011E60`): классический двоичный поиск по отсортированному массиву индексов. Возвращает оригинальный индекс записи или `-1` при отсутствии.
+- `directory_size = entry_count * 64`;
+- `directory_offset = total_size - directory_size`.
-#### Поле «Смещение данных» (смещение 56)
+Ограничения:
-Абсолютное смещение от начала файла. Данные читаются из mapped view: `pointer = mapped_base + data_offset`.
+- `directory_offset >= 16`;
+- `directory_offset + directory_size == total_size`.
-### 1.5. Выравнивание данных
+## 4. Запись каталога (64 байта)
-При добавлении ресурса его данные записываются последовательно, после чего выполняется **выравнивание по 8-байтной границе**:
+| Offset | Size | Type | Поле |
+|---:|---:|---|---|
+| 0 | 4 | u32 | `type_id` |
+| 4 | 4 | u32 | `attr1` |
+| 8 | 4 | u32 | `attr2` |
+| 12 | 4 | u32 | `size` (размер payload) |
+| 16 | 4 | u32 | `attr3` |
+| 20 | 36 | char[36] | `name_raw` (C-строка) |
+| 56 | 4 | u32 | `data_offset` |
+| 60 | 4 | u32 | `sort_index` |
-```c
-padding = ((data_size + 7) & ~7) - data_size;
-// Если padding > 0, записываются нулевые байты
-```
-
-Таким образом, каждый блок данных начинается с адреса, кратного 8.
-
-При изменении размера данных ресурса выполняется сдвиг всех последующих данных и обновление смещений всех затронутых записей каталога.
-
-### 1.6. Создание файла (API `niCreateResFile`)
-
-При создании нового файла:
+### 4.1. Имя ресурса (`name_raw`)
-1. Если файл уже существует и содержит корректный NRes-архив, существующий каталог считывается с конца файла, а файл усекается до начала каталога.
-2. Если файл пуст или не является NRes-архивом, создаётся новый с пустым каталогом. Поля `entry_count = 0`, `total_size = 16`.
+Контракт:
-При закрытии файла (`sub_100122D0`):
+- максимум 35 полезных байт + NUL;
+- допускается ровно один терминатор внутри 36-байтового поля;
+- имя сравнивается регистронезависимо по ASCII-правилу (`A..Z` -> `a..z`).
-1. Заголовок переписывается в начало файла (16 байт).
-2. Вычисляется `total_size = data_end_offset + entry_count × 64`.
-3. Индексы сортировки пересчитываются.
-4. Каталог записей записывается в конец файла.
+Для writer/editor:
-### 1.7. Режимы сортировки каталога
+- запрещено писать NUL внутри полезной части имени;
+- запрещены имена длиной > 35 байт.
-Функция `sub_10012560` поддерживает 12 режимов сортировки (0–11):
+### 4.2. Диапазон данных (`data_offset`, `size`)
-| Режим | Порядок сортировки |
-| ----- | --------------------------------- |
-| 0 | Без сортировки (сброс) |
-| 1 | По атрибуту 1 (смещение 4) |
-| 2 | По атрибуту 2 (смещение 8) |
-| 3 | По (атрибут 1, атрибут 2) |
-| 4 | По типу ресурса (смещение 0) |
-| 5 | По (тип, атрибут 1) |
-| 6 | По (тип, атрибут 1) — идентичен 5 |
-| 7 | По (тип, атрибут 1, атрибут 2) |
-| 8 | По имени (регистронезависимо) |
-| 9 | По (тип, имя) |
-| 10 | По (атрибут 1, имя) |
-| 11 | По (атрибут 2, имя) |
+Для каждой записи:
-### 1.8. Операция `niOpenResFileEx` — флаги открытия
+- `data_offset >= 16`;
+- `data_offset + size <= directory_offset`.
-Второй параметр — битовые флаги:
+Практически (канонический writer): каждый payload начинается с 8-байтного выравнивания.
-| Бит | Маска | Описание |
-| --- | ----- | ----------------------------------------------------------------------------------- |
-| 0 | 0x01 | Sequential scan hint (`FILE_FLAG_SEQUENTIAL_SCAN` вместо `FILE_FLAG_RANDOM_ACCESS`) |
-| 1 | 0x02 | Открыть для записи (read-write). Без флага — только чтение |
-| 2 | 0x04 | Пометить файл как «кэшируемый» (не выгружать при refcount=0) |
-| 3 | 0x08 | Raw-режим: не проверять заголовок NRes, трактовать весь файл как единый ресурс |
-
-### 1.9. Виртуальное касание страниц
-
-Функция `sub_100197D0` выполняет «касание» страниц памяти для принудительной загрузки из memory-mapped файла. Она обходит адресное пространство с шагом 4096 байт (размер страницы), начиная с 0x10000 (64 КБ):
-
-```
-for (result = 0x10000; result < size; result += 4096);
-```
+## 5. Таблица сортировки (`sort_index`)
-Вызывается при чтении данных ресурса с флагом `a3 != 0` для предзагрузки данных в оперативную память.
+`sort_index` задает перестановку «отсортированный список -> исходный индекс записи».
----
+Пусть:
-## Часть 2. Формат RsLi
+- `entries[i]` — i-я запись каталога в исходном порядке;
+- `P` — массив индексов `0..entry_count-1`, отсортированный по `entries[idx].name` (ASCII case-insensitive).
-### 2.1. Общая структура файла
+Тогда в канонической записи:
-```
-┌───────────────────────────────┐ Смещение 0
-│ Заголовок файла (32 байта) │
-├───────────────────────────────┤ Смещение 32
-│ Таблица записей (зашифрована)│
-│ (entry_count × 32 байт) │
-├───────────────────────────────┤ Смещение 32 + entry_count × 32
-│ │
-│ Данные ресурсов │
-│ │
-├───────────────────────────────┤
-│ [Опциональный трейлер — 6 б] │
-└───────────────────────────────┘
-```
-
-### 2.2. Заголовок файла (32 байта)
-
-| Смещение | Размер | Тип | Значение | Описание |
-| -------- | ------ | ------- | ----------------- | --------------------------------------------- |
-| 0 | 2 | char[2] | `NL` (0x4C4E) | Магическая сигнатура |
-| 2 | 1 | uint8 | `0x00` | Зарезервировано (должно быть 0) |
-| 3 | 1 | uint8 | `0x01` | Версия формата |
-| 4 | 2 | int16 | — | Количество записей (sign-extended при чтении) |
-| 6 | 8 | — | — | Зарезервировано / не используется |
-| 14 | 2 | uint16 | `0xABBA` или иное | Флаг предсортировки (см. ниже) |
-| 16 | 4 | — | — | Зарезервировано |
-| 20 | 4 | uint32 | — | **Начальное состояние XOR-шифра** (seed) |
-| 24 | 8 | — | — | Зарезервировано |
-
-#### Флаг предсортировки (смещение 14)
-
-- Если `*(uint16*)(header + 14) == 0xABBA` — движок **не строит** таблицу индексов в памяти. Значения `entry[i].sort_to_original` используются **как есть** (и для двоичного поиска, и как XOR‑ключ для данных).
-- Если значение **отлично от 0xABBA** — после загрузки выполняется **пузырьковая сортировка** имён и строится перестановка `sort_to_original[]`, которая затем **записывается в `entry[i].sort_to_original`**, перетирая значения из файла. Именно эта перестановка далее используется и для поиска, и как XOR‑ключ (младшие 16 бит).
-
-### 2.3. XOR-шифр таблицы записей
-
-Таблица записей начинается со смещения 32 и зашифрована поточным XOR-шифром. Ключ инициализируется из DWORD по смещению 20 заголовка.
-
-#### Начальное состояние
-
-```
-seed = *(uint32*)(header + 20)
-lo = seed & 0xFF // Младший байт
-hi = (seed >> 8) & 0xFF // Второй байт
-```
-
-#### Алгоритм дешифровки (побайтовый)
-
-Для каждого зашифрованного байта `encrypted[i]`, начиная с `i = 0`:
-
-```
-step 1: lo = hi ^ ((lo << 1) & 0xFF) // Сдвиг lo влево на 1, XOR с hi
-step 2: decrypted[i] = lo ^ encrypted[i] // Расшифровка байта
-step 3: hi = lo ^ ((hi >> 1) & 0xFF) // Сдвиг hi вправо на 1, XOR с lo
-```
-
-**Пример реализации:**
-
-```python
-def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
- lo = seed & 0xFF
- hi = (seed >> 8) & 0xFF
- result = bytearray(len(encrypted_data))
- for i in range(len(encrypted_data)):
- lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
- result[i] = lo ^ encrypted_data[i]
- hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
- return bytes(result)
-```
+- `entries[i].sort_index = P[i]`.
-Этот же алгоритм используется для шифрования данных ресурсов с методом XOR (флаги 0x20, 0x60, 0xA0), но с другим начальным ключом из записи.
+Это именно таблица для бинарного поиска по имени, а не «ранг текущей записи».
-### 2.4. Запись таблицы (32 байта, на диске, до дешифровки)
+## 6. Поиск по имени
-После дешифровки каждая запись имеет следующую структуру:
+Алгоритм поиска:
-| Смещение | Размер | Тип | Описание |
-| -------- | ------ | -------- | -------------------------------------------------------------- |
-| 0 | 12 | char[12] | Имя ресурса (ASCII, обычно uppercase; строка читается до `\0`) |
-| 12 | 4 | — | Зарезервировано (движком игнорируется) |
-| 16 | 2 | int16 | **Флаги** (метод сжатия и атрибуты) |
-| 18 | 2 | int16 | **`sort_to_original[i]` / XOR‑ключ** (см. ниже) |
-| 20 | 4 | uint32 | **Размер распакованных данных** (`unpacked_size`) |
-| 24 | 4 | uint32 | Смещение данных от начала файла (`data_offset`) |
-| 28 | 4 | uint32 | Размер упакованных данных в байтах (`packed_size`) |
-
-#### Имена ресурсов
-
-- Поле `name[12]` копируется побайтно. Внутренне движок всегда имеет `\0` сразу после этих 12 байт (зарезервированные 4 байта в памяти принудительно обнуляются), поэтому имя **может быть длиной до 12 символов** даже без `\0` внутри `name[12]`.
-- На практике имена обычно **uppercase ASCII**. `rsFind` приводит запрос к верхнему регистру (`_strupr`) и сравнивает побайтно.
-- `rsFind` копирует имя запроса `strncpy(..., 16)` и принудительно ставит `\0` в `Destination[15]`, поэтому запрос длиннее 15 символов будет усечён.
-
-#### Поле `sort_to_original[i]` (смещение 18)
-
-Это **не “свойство записи”**, а элемент таблицы индексов, по которой `rsFind` делает двоичный поиск:
-
-- Таблица реализована “внутри записей”: значение берётся как `entry[i].sort_to_original` (где `i` — позиция двоичного поиска), а реальная запись для сравнения берётся как `entry[ sort_to_original[i] ]`.
-- Тем же значением (младшие 16 бит) инициализируется XOR‑шифр данных для методов, где он используется (0x20/0x60/0xA0). Поэтому при упаковке/шифровании данных ключ должен совпадать с итоговым `sort_to_original[i]` (см. флаг 0xABBA в разделе 2.2).
-
-Поиск выполняется **двоичным поиском** по этой таблице, с фолбэком на **линейный поиск** если двоичный не нашёл (поведение `rsFind`).
-
-### 2.5. Поле флагов (смещение 16 записи)
-
-Биты поля флагов кодируют метод сжатия и дополнительные атрибуты:
-
-```
-Биты [8:5] (маска 0x1E0): Метод сжатия/шифрования
-Бит [6] (маска 0x040): Флаг realloc (буфер декомпрессии может быть больше)
-```
-
-#### Методы сжатия (биты 8–5, маска 0x1E0)
-
-| Значение | Hex | Описание |
-| -------- | ----- | --------------------------------------- |
-| 0x000 | 0x00 | Без сжатия (копирование) |
-| 0x020 | 0x20 | Только XOR-шифр |
-| 0x040 | 0x40 | LZSS (простой вариант) |
-| 0x060 | 0x60 | XOR-шифр + LZSS (простой вариант) |
-| 0x080 | 0x80 | LZSS с адаптивным кодированием Хаффмана |
-| 0x0A0 | 0xA0 | XOR-шифр + LZSS с Хаффманом |
-| 0x100 | 0x100 | Deflate (аналог zlib/RFC 1951) |
-
-Примечание: `rsGetPackMethod()` возвращает `flags & 0x1C0` (без бита 0x20). Поэтому:
-
-- для 0x20 вернётся 0x00,
-- для 0x60 вернётся 0x40,
-- для 0xA0 вернётся 0x80.
-
-#### Бит 0x40 (выделение +0x12 и последующее `realloc`)
-
-Бит 0x40 проверяется отдельно (`flags & 0x40`). Если он установлен, выходной буфер выделяется с запасом `+0x12` (18 байт), а после распаковки вызывается `realloc` для усечения до точного `unpacked_size`.
-
-Важно: этот же бит входит в код методов 0x40/0x60, поэтому для них поведение “+0x12 и shrink” включено автоматически.
-
-### 2.6. Размеры данных
-
-В каждой записи на диске хранятся оба значения:
-
-- `unpacked_size` (смещение 20) — размер распакованных данных.
-- `packed_size` (смещение 28) — размер упакованных данных (байт во входном потоке для выбранного метода).
-
-Для метода 0x00 (без сжатия) обычно `packed_size == unpacked_size`.
-
-`rsGetInfo` возвращает именно `unpacked_size` (то, сколько байт выдаст `rsLoad`).
-
-Практический нюанс для метода `0x100` (Deflate): в реальных игровых данных встречается запись, где `packed_size` указывает на диапазон до `EOF + 1`. Поток успешно декодируется и без последнего байта; это похоже на lookahead-поведение декодера.
-
-### 2.7. Опциональный трейлер медиа (6 байт)
-
-При открытии с флагом `a2 & 2`:
-
-| Смещение от конца | Размер | Тип | Описание |
-| ----------------- | ------ | ------- | ----------------------- |
-| −6 | 2 | char[2] | Сигнатура `AO` (0x4F41) |
-| −4 | 4 | uint32 | Смещение медиа-оверлея |
-
-Если трейлер присутствует, все смещения данных в записях корректируются: `effective_offset = entry_offset + media_overlay_offset`.
-
----
-
-## Часть 3. Алгоритмы сжатия (формат RsLi)
-
-### 3.1. XOR-шифр данных (метод 0x20)
-
-Алгоритм идентичен XOR‑шифру таблицы записей (раздел 2.3), но начальный ключ берётся из `entry[i].sort_to_original` (смещение 18 записи, младшие 16 бит).
-
-Важно про размер входа:
-
-- В ветке **0x20** движок XOR‑ит ровно `unpacked_size` байт (и ожидает, что поток данных имеет ту же длину; на практике `packed_size == unpacked_size`).
-- В ветках **0x60/0xA0** XOR применяется к **упакованному** потоку длиной `packed_size` перед декомпрессией.
-
-#### Инициализация
-
-```
-key16 = (uint16)entry.sort_to_original // int16 на диске по смещению 18
-lo = key16 & 0xFF
-hi = (key16 >> 8) & 0xFF
-```
+1. Выполнить бинарный поиск по диапазону `i in [0, entry_count)`.
+2. На шаге `i` взять `target = entries[i].sort_index`.
+3. Сравнить искомое имя с `entries[target].name` (ASCII case-insensitive).
+4. При совпадении вернуть `target`.
-#### Дешифровка (псевдокод)
-
-```
-for i in range(N): # N = unpacked_size (для 0x20) или packed_size (для 0x60/0xA0)
- lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
- out[i] = in[i] ^ lo
- hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
-```
-
-### 3.2. LZSS — простой вариант (метод 0x40)
-
-Классический алгоритм LZSS (Lempel-Ziv-Storer-Szymanski) с кольцевым буфером.
-
-#### Параметры
-
-| Параметр | Значение |
-| ----------------------------- | ------------------ |
-| Размер кольцевого буфера | 4096 байт (0x1000) |
-| Начальная позиция записи | 4078 (0xFEE) |
-| Начальное заполнение | 0x20 (пробел) |
-| Минимальная длина совпадения | 3 |
-| Максимальная длина совпадения | 18 (4 бита + 3) |
-
-#### Алгоритм декомпрессии
-
-```
-Инициализация:
- ring_buffer[0..4095] = 0x20 (заполнить пробелами)
- ring_pos = 4078
- flags_byte = 0
- flags_bits_remaining = 0
-
-Цикл (пока не заполнен выходной буфер И не исчерпан входной):
-
- 1. Если flags_bits_remaining == 0:
- - Прочитать 1 байт из входного потока → flags_byte
- - flags_bits_remaining = 8
-
- Декодировать как:
- - Старший бит устанавливается в 0x7F (маркер)
- - Оставшиеся 7 бит — флаги текущей группы
-
- Реально в коде: control_word = (flags_byte) | (0x7F << 8)
- Каждый бит проверяется сдвигом вправо.
-
- 2. Проверить младший бит control_word:
-
- Если бит = 1 (литерал):
- - Прочитать 1 байт из входного потока → byte
- - ring_buffer[ring_pos] = byte
- - ring_pos = (ring_pos + 1) & 0xFFF
- - Записать byte в выходной буфер
-
- Если бит = 0 (ссылка):
- - Прочитать 2 байта: low_byte, high_byte
- - offset = low_byte | ((high_byte & 0xF0) << 4) // 12 бит
- - length = (high_byte & 0x0F) + 3 // 4 бита + 3
- - Скопировать length байт из ring_buffer[offset...]:
- для j от 0 до length-1:
- byte = ring_buffer[(offset + j) & 0xFFF]
- ring_buffer[ring_pos] = byte
- ring_pos = (ring_pos + 1) & 0xFFF
- записать byte в выходной буфер
-
- 3. Сдвинуть control_word вправо на 1 бит
- 4. flags_bits_remaining -= 1
-```
-
-#### Подробная раскладка пары ссылки (2 байта)
-
-```
-Байт 0 (low): OOOOOOOO (биты [7:0] смещения)
-Байт 1 (high): OOOOLLLL O = биты [11:8] смещения, L = длина − 3
-
-offset = low | ((high & 0xF0) << 4) // Диапазон: 0–4095
-length = (high & 0x0F) + 3 // Диапазон: 3–18
-```
-
-### 3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80)
-
-Расширенный вариант LZSS, где литералы и длины совпадений кодируются с помощью адаптивного дерева Хаффмана.
-
-#### Параметры
-
-| Параметр | Значение |
-| -------------------------------- | ------------------------------ |
-| Размер кольцевого буфера | 4096 байт |
-| Начальная позиция записи | **4036** (0xFC4) |
-| Начальное заполнение | 0x20 (пробел) |
-| Количество листовых узлов дерева | 314 |
-| Символы литералов | 0–255 (байты) |
-| Символы длин | 256–313 (длина = символ − 253) |
-| Начальная длина | 3 (при символе 256) |
-| Максимальная длина | 60 (при символе 313) |
-
-#### Дерево Хаффмана
-
-Дерево строится как **адаптивное** (dynamic, self-adjusting):
-
-- **627 узлов**: 314 листовых + 313 внутренних.
-- Все листья изначально имеют **вес 1**.
-- Корень дерева — узел с индексом 0 (в массиве `parent`).
-- После декодирования каждого символа дерево **обновляется** (функция `sub_1001B0AE`): вес узла инкрементируется, и при нарушении порядка узлы **переставляются** для поддержания свойства.
-- При достижении суммарного веса **0x8000 (32768)** — все веса **делятся на 2** (с округлением вверх) и дерево полностью перестраивается.
-
-#### Кодирование позиции
-
-Позиция в кольцевом буфере кодируется с помощью **d-кода** (таблица дистанций):
-
-- 8 бит позиции ищутся в таблице `d_code[256]`, определяя базовое значение и количество дополнительных битов.
-- Из потока считываются дополнительные биты, которые объединяются с базовым значением.
-- Финальная позиция: `pos = (ring_pos − 1 − decoded_position) & 0xFFF`
-
-**Таблицы инициализации** (d-коды):
-
-```
-Таблица базовых значений — byte_100371D0[6]:
- { 0x01, 0x03, 0x08, 0x0C, 0x18, 0x10 }
-
-Таблица дополнительных битов — byte_100371D6[6]:
- { 0x20, 0x30, 0x40, 0x30, 0x30, 0x10 }
-```
-
-#### Алгоритм декомпрессии (высокоуровневый)
-
-```
-Инициализация:
- ring_buffer[0..4095] = 0x20
- ring_pos = 4036
- Инициализировать дерево Хаффмана (314 листьев, все веса = 1)
- Инициализировать таблицы d-кодов
-
-Цикл:
- 1. Декодировать символ из потока по дереву Хаффмана:
- - Начать с корня
- - Читать биты, спускаться по дереву (0 = левый, 1 = правый)
- - Пока не достигнут лист → символ = лист − 627
-
- 2. Обновить дерево Хаффмана для декодированного символа
-
- 3. Если символ < 256 (литерал):
- - ring_buffer[ring_pos] = символ
- - ring_pos = (ring_pos + 1) & 0xFFF
- - Записать символ в выходной буфер
-
- 4. Если символ >= 256 (ссылка):
- - length = символ − 253
- - Декодировать позицию через d-код:
- a) Прочитать 8 бит из потока
- b) Найти d-код и дополнительные биты по таблице
- c) Прочитать дополнительные биты
- d) position = (ring_pos − 1 − full_position) & 0xFFF
- - Скопировать length байт из ring_buffer[position...]
-
- 5. Если выходной буфер заполнен → завершить
-```
-
-### 3.4. XOR + LZSS (методы 0x60 и 0xA0)
-
-Комбинированный метод: сначала XOR-дешифровка, затем LZSS-декомпрессия.
-
-#### Алгоритм
-
-1. Выделить временный буфер размером `compressed_size` (поле из записи, смещение 28).
-2. Дешифровать сжатые данные XOR-шифром (раздел 3.1) с ключом из записи во временный буфер.
-3. Применить LZSS-декомпрессию (простую или с Хаффманом, в зависимости от конкретного метода) из временного буфера в выходной.
-4. Освободить временный буфер.
-
-- **0x60** — XOR + простой LZSS (раздел 3.2)
-- **0xA0** — XOR + LZSS с Хаффманом (раздел 3.3)
-
-#### Начальное состояние XOR для данных
-
-При комбинированном методе seed берётся из поля по смещению 20 записи (4-байтный). Однако ключ обрабатывается как 16-битный: `lo = seed & 0xFF`, `hi = (seed >> 8) & 0xFF`.
-
-### 3.5. Deflate (метод 0x100)
-
-Полноценная реализация алгоритма **Deflate** (RFC 1951) с блочной структурой.
-
-#### Общая структура
-
-Данные состоят из последовательности блоков. Каждый блок начинается с:
-
-- **1 бит** — `is_final`: признак последнего блока
-- **2 бита** — `block_type`: тип блока
-
-#### Типы блоков
-
-| block_type | Описание | Функция |
-| ---------- | --------------------------- | ---------------- |
-| 0 | Без сжатия (stored) | `sub_1001A750` |
-| 1 | Фиксированные коды Хаффмана | `sub_1001A8C0` |
-| 2 | Динамические коды Хаффмана | `sub_1001AA30` |
-| 3 | Зарезервировано (ошибка) | Возвращает код 2 |
-
-#### Блок типа 0 (stored)
-
-1. Отбросить оставшиеся биты до границы байта (выравнивание).
-2. Прочитать 16 бит — `LEN` (длина блока).
-3. Прочитать 16 бит — `NLEN` (дополнение длины, `NLEN == ~LEN & 0xFFFF`).
-4. Проверить: `LEN == (uint16)(~NLEN)`. При несовпадении — ошибка.
-5. Скопировать `LEN` байт из входного потока в выходной.
-
-Декомпрессор использует внутренний буфер размером **32768 байт** (0x8000). При заполнении — промежуточная запись результата.
-
-#### Блок типа 1 (фиксированные коды)
-
-Стандартные коды Deflate:
-
-- Литералы/длины: 288 кодов
- - 0–143: 8-битные коды
- - 144–255: 9-битные коды
- - 256–279: 7-битные коды
- - 280–287: 8-битные коды
-- Дистанции: 30 кодов, все 5-битные
-
-Используются предопределённые таблицы длин и дистанций (`unk_100370AC`, `unk_1003712C` и соответствующие экстра-биты).
-
-#### Блок типа 2 (динамические коды)
-
-1. Прочитать 5 бит → `HLIT` (количество литералов/длин − 257). Диапазон: 257–286.
-2. Прочитать 5 бит → `HDIST` (количество дистанций − 1). Диапазон: 1–30.
-3. Прочитать 4 бита → `HCLEN` (количество кодов длин − 4). Диапазон: 4–19.
-4. Прочитать `HCLEN` × 3 бит — длины кодов для алфавита длин.
-5. Построить дерево Хаффмана для алфавита длин (19 символов).
-6. С помощью этого дерева декодировать длины кодов для литералов/длин и дистанций.
-7. Построить два дерева Хаффмана: для литералов/длин и для дистанций.
-8. Декодировать данные.
-
-**Порядок кодов длин** (стандартный Deflate):
-
-```
-{ 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 }
-```
-
-Хранится в `dword_10037060`.
-
-#### Валидации
-
-- `HLIT + 257 <= 286` (max 0x11E)
-- `HDIST + 1 <= 30` (max 0x1E)
-- При нарушении — возвращается ошибка 1.
-
-### 3.6. Метод 0x00 (без сжатия)
-
-Данные копируются «как есть» напрямую из файла. Вызывается через указатель на функцию `dword_1003A1B8` (фактически `memcpy` или аналог).
-
----
-
-## Часть 4. Внутренние структуры в памяти
-
-### 4.1. Внутренняя структура NRes-архива (opened, 0x68 байт = 104)
-
-```c
-struct NResArchive { // Размер: 0x68 (104 байта)
- void* vtable; // +0: Указатель на таблицу виртуальных методов
- int32_t entry_count; // +4: Количество записей
- void* mapped_base; // +8: Базовый адрес mapped view
- void* directory_ptr; // +12: Указатель на каталог записей в памяти
- char* filename; // +16: Путь к файлу (_strdup)
- int32_t ref_count; // +20: Счётчик ссылок
- uint32_t last_release_time; // +24: timeGetTime() при последнем Release
- // +28..+91: Для raw-режима — встроенная запись (единственный File entry)
- NResArchive* next; // +92: Следующий архив в связном списке
- uint8_t is_writable; // +100: Файл открыт для записи
- uint8_t is_cacheable; // +101: Не выгружать при refcount = 0
-};
-```
-
-### 4.2. Внутренняя структура RsLi-архива (56 + 64 × N байт)
-
-```c
-struct RsLibHeader { // 56 байт (14 DWORD)
- uint32_t magic; // +0: 'RsLi' (0x694C7352)
- int32_t entry_count; // +4: Количество записей
- uint32_t media_offset; // +8: Смещение медиа-оверлея
- uint32_t reserved_0C; // +12: 0
- HANDLE file_handle_2; // +16: -1 (дополнительный хэндл)
- uint32_t reserved_14; // +20: 0
- uint32_t reserved_18; // +24: —
- uint32_t reserved_1C; // +28: 0
- HANDLE mapping_handle_2; // +32: -1
- uint32_t reserved_24; // +36: 0
- uint32_t flag_28; // +40: (flags >> 7) & 1
- HANDLE file_handle; // +44: Хэндл файла
- HANDLE mapping_handle; // +48: Хэндл файлового маппинга
- void* mapped_view; // +52: Указатель на mapped view
-};
-// Далее следуют entry_count записей по 64 байта каждая
-```
-
-#### Внутренняя запись RsLi (64 байта)
-
-```c
-struct RsLibEntry { // 64 байта (16 DWORD)
- char name[16]; // +0: Имя (12 из файла + 4 нуля)
- int32_t flags; // +16: Флаги (sign-extended из int16)
- int32_t sort_index; // +20: sort_to_original[i] (таблица индексов / XOR‑ключ)
- uint32_t uncompressed_size; // +24: Размер несжатых данных (из поля 20 записи)
- void* data_ptr; // +28: Указатель на данные в mapped view
- uint32_t compressed_size; // +32: Размер сжатых данных (из поля 28 записи)
- uint32_t reserved_24; // +36: 0
- uint32_t reserved_28; // +40: 0
- uint32_t reserved_2C; // +44: 0
- void* loaded_data; // +48: Указатель на декомпрессированные данные
- // +52..+63: дополнительные поля
-};
-```
+Fail-safe поведение:
----
+- если `sort_index` некорректен (выход за диапазон), реализация должна перейти на линейный fallback по всем записям;
+- fallback использует то же ASCII case-insensitive сравнение.
-## Часть 5. Экспортируемые API-функции
+## 7. Каноническая пересборка архива
-### 5.1. NRes API
+Канонический writer выполняет:
-| Функция | Описание |
-| ------------------------------ | ------------------------------------------------------------------------- |
-| `niOpenResFile(path)` | Открыть NRes-архив (только чтение), эквивалент `niOpenResFileEx(path, 0)` |
-| `niOpenResFileEx(path, flags)` | Открыть NRes-архив с флагами |
-| `niOpenResInMem(ptr, size)` | Открыть NRes-архив из памяти |
-| `niCreateResFile(path)` | Создать/открыть NRes-архив для записи |
+1. Пишет заглушку заголовка (16 байт).
+2. Пишет payload всех записей в текущем порядке.
+3. После каждого payload добавляет 0-padding до кратности 8.
+4. Пересчитывает `sort_index` через сортировку имен.
+5. Дописывает каталог (`entry_count * 64`).
+6. Пересчитывает и записывает `total_size`.
-### 5.2. RsLi API
+Итоговый файл должен удовлетворять всем ограничениям из разделов 3–5.
-| Функция | Описание |
-| ------------------------------- | -------------------------------------------------------- |
-| `rsOpenLib(path, flags)` | Открыть RsLi-библиотеку |
-| `rsCloseLib(lib)` | Закрыть библиотеку |
-| `rsLibNum(lib)` | Получить количество записей |
-| `rsFind(lib, name)` | Найти запись по имени (→ индекс или −1) |
-| `rsLoad(lib, index)` | Загрузить и декомпрессировать ресурс |
-| `rsLoadFast(lib, index, flags)` | Быстрая загрузка (без декомпрессии если возможно) |
-| `rsLoadPacked(lib, index)` | Загрузить в «упакованном» виде (отложенная декомпрессия) |
-| `rsLoadByName(lib, name)` | `rsFind` + `rsLoad` |
-| `rsGetInfo(lib, index, out)` | Получить имя и размер ресурса |
-| `rsGetPackMethod(lib, index)` | Получить метод сжатия (`flags & 0x1C0`) |
-| `ngiUnpack(packed)` | Декомпрессировать ранее загруженный упакованный ресурс |
-| `ngiAlloc(size)` | Выделить память (с обработкой ошибок) |
-| `ngiFree(ptr)` | Освободить память |
-| `ngiGetMemSize(ptr)` | Получить размер выделенного блока |
+## 8. Режим `raw` (совместимость инструментов)
----
+Для служебных инструментов допускается `raw_mode`:
-## Часть 6. Контрольные заметки для реализации
+- любой бинарный файл трактуется как один «сырой» ресурс;
+- возвращается одна запись (`name = RAW`, `data_offset = 0`, `size = len(file)`).
-### 6.1. Кодировки и регистр
+Этот режим не является форматом `NRes` на диске, это только режим открытия.
-- **NRes**: имена хранятся **как есть** (case-insensitive при поиске через `_strcmpi`).
-- **RsLi**: имена хранятся в **верхнем регистре**. Перед поиском запрос приводится к верхнему регистру (`_strupr`). Сравнение — через `strcmp` (case-sensitive для уже uppercase строк).
+## 9. Контрольные инварианты
-### 6.2. Порядок байт
+Минимальный набор проверок при чтении:
-Все значения хранятся в **little-endian** порядке (платформа x86/Win32).
+1. `magic == "NRes"`.
+2. `version == 0x100`.
+3. `entry_count >= 0`.
+4. `header.total_size == file_size`.
+5. Каталог находится в конце файла.
+6. Для каждой записи диапазон данных не пересекает каталог.
+7. Имя корректно C-терминировано и не длиннее 35 байт.
-### 6.3. Выравнивание
+Минимальный набор проверок при записи:
-- **NRes**: данные каждого ресурса выровнены по границе **8 байт** (0-padding между файлами).
-- **RsLi**: выравнивание данных не описано в коде (данные идут подряд).
+1. Все имена <= 35 байт и без внутренних NUL.
+2. `sort_index` формирует валидную перестановку `0..N-1`.
+3. Все паддинги между payload состоят из нулевых байт.
+4. `total_size` равен фактической длине выходного файла.
-### 6.4. Размер записей на диске
+## 10. Эмпирическая проверка на retail-корпусе
-- **NRes**: каталог — **64 байта** на запись, расположен в конце файла.
-- **RsLi**: таблица — **32 байта** на запись (зашифрованная), расположена в начале файла (сразу после 32-байтного заголовка).
+Валидация на полном наборе `testdata/Parkan - Iron Strategy`:
-### 6.5. Кэширование и memory mapping
+- найдено `120` архивов `NRes`;
+- roundtrip `unpack -> repack -> byte-compare`: `120/120` совпали побайтно;
+- критических расхождений формата не обнаружено.
-Оба формата используют Windows Memory-Mapped Files (`CreateFileMapping` + `MapViewOfFile`). NRes-архивы организованы в глобальный **связный список** (`dword_1003A66C`) со счётчиком ссылок и таймером неактивности (10 секунд = 0x2710 мс). При refcount == 0 и истечении таймера архив автоматически выгружается (если не установлен флаг `is_cacheable`).
+Инструмент:
-### 6.6. Размер seed XOR
+- `tools/archive_roundtrip_validator.py`
-- **Заголовок RsLi**: seed — **4 байта** (DWORD) по смещению 20, но используются только младшие 2 байта (`lo = byte[0]`, `hi = byte[1]`).
-- **Запись RsLi**: sort_to_original[i] — **2 байта** (int16) по смещению 18 записи.
-- **Данные при комбинированном XOR+LZSS**: seed — **4 байта** (DWORD) из поля по смещению 20 записи, но опять используются только 2 байта.
+## 11. Статус покрытия и что осталось до 100%
-### 6.7. Эмпирическая проверка на данных игры
+Закрыто:
-- Найдено архивов по сигнатуре: **122** (`NRes`: 120, `RsLi`: 2).
-- Выполнен полный roundtrip `unpack -> pack -> byte-compare`: **122/122** архивов совпали побайтно.
-- Для `RsLi` в проверенном наборе встретились методы: `0x040` и `0x100`.
+- формат заголовка/каталога;
+- правила поиска;
+- каноническая пересборка;
+- строгие инварианты валидатора;
+- побайтовый roundtrip на retail-корпусе.
-Подтверждённые нюансы:
+Осталось до полного 100% архитектурного покрытия движка:
-- Для LZSS (метод `0x040`) рабочая раскладка нибблов в ссылке: `OOOO LLLL`, а не `LLLL OOOO`.
-- Для Deflate (метод `0x100`) возможен случай `packed_size == фактический_конец + 1` на последней записи файла.
+1. Формальная семантика `attr1/attr2/attr3` для всех типов ресурсов (частично вынесена в профильные страницы `msh`, `material`, `texture`, `fxid`, `terrain`).
+2. Полная спецификация поведения при не-ASCII именах (в реальных игровых архивах используется ASCII-практика; для Unicode-коллации движок не документирован).
+3. Полная спецификация платформенных гарантий атомарной записи (формат данных закрыт, но OS-уровневые гарантии замены файла зависят от платформы и файловой системы).