Библиотека `Ngi32.dll` реализует два различных формата архивов ресурсов:
1.**NRes** — основной формат архива ресурсов, используемый через API `niOpenResFile` / `niCreateResFile`. Каталог файлов расположен в **конце** файла. Поддерживает создание, редактирование, добавление и удаление записей.
2.**RsLi** — формат библиотеки ресурсов, используемый через API `rsOpenLib` / `rsLoad`. Таблица записей расположена **в начале** файла (сразу после заголовка) и зашифрована XOR-шифром. Поддерживает несколько методов сжатия. Только чтение.
| 4 | 4 | uint32 | `0x00000100` (256) | Версия формата (1.0) |
| 8 | 4 | int32 | — | Количество записей в каталоге |
| 12 | 4 | int32 | — | Полный размер файла в байтах |
**Валидация при открытии:** магическая сигнатура и версия должны совпадать точно. Поле `total_size` (смещение 12) **проверяется на равенство**с фактическим размером файла (`GetFileSize`). Если значения не совпадают — файл отклоняется.
## 1.3. Положение каталога в файле
Каталог располагается в самом конце файла. Его смещение вычисляется по формуле:
```
directory_offset = total_size - entry_count × 64
```
Данные ресурсов занимают пространство между заголовком (16 байт) и каталогом.
## 1.4. Запись каталога (64 байта)
Каждая запись каталога занимает ровно **64 байта** (0x40):
| 56 | 4 | uint32 | Смещение данных от начала файла |
| 60 | 4 | uint32 | Индекс сортировки (для двоичного поиска по имени) |
### Поле «Имя файла» (смещение 20, 36 байт)
- Максимальная длина имени: **35 символов** + 1 байт null-терминатор.
- При записи поле сначала обнуляется (`memset(0, 36 байт)`), затем копируется имя (`strncpy`, макс. 35 символов).
- Поиск по имени выполняется **без учёта регистра** (`_strcmpi`).
### Поле «Индекс сортировки» (смещение 60)
Используется для **двоичного поиска по имени**. Содержит индекс оригинальной записи, отсортированной в алфавитном порядке (регистронезависимо). Индекс строится при сохранении файла функцией `sub_10013260`с помощью **пузырьковой сортировки** по именам.
**Алгоритм поиска** (`sub_10011E60`): классический двоичный поиск по отсортированному массиву индексов. Возвращает оригинальный индекс записи или `-1` при отсутствии.
### Поле «Смещение данных» (смещение 56)
Абсолютное смещение от начала файла. Данные читаются из mapped view: `pointer = mapped_base + data_offset`.
## 1.5. Выравнивание данных
При добавлении ресурсаего данные записываются последовательно, после чего выполняется **выравнивание по 8-байтной границе**:
```c
padding = ((data_size + 7) & ~7) - data_size;
// Если padding > 0, записываются нулевые байты
```
Таким образом, каждый блок данных начинается с адреса, кратного 8.
При изменении размера данных ресурса выполняется сдвиг всех последующих данных и обновление смещений всех затронутых записей каталога.
## 1.6. Создание файла (API `niCreateResFile`)
При создании нового файла:
1. Если файл уже существует и содержит корректный NRes-архив, существующий каталог считывается с конца файла, а файл усекается до начала каталога.
2. Если файл пуст или не является NRes-архивом, создаётся новый с пустым каталогом. Поля `entry_count = 0`, `total_size = 16`.
При закрытии файла (`sub_100122D0`):
1. Заголовок переписывается в начало файла (16 байт).
| 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);
```
Вызывается при чтении данных ресурсас флагом `a3 != 0` для предзагрузки данных в оперативную память.
- Если `*(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
| 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 (буфер декомпрессии может быть больше)
### Бит 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-поведение декодера.
Если трейлер присутствует, все смещения данных в записях корректируются: `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
```
### Дешифровка (псевдокод)
```
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) с кольцевым буфером.
Дерево строится как **адаптивное** (dynamic, self-adjusting):
- **627 узлов**: 314 листовых + 313 внутренних.
-Все листья изначально имеют **вес 1**.
- Корень дерева — узел с индексом 0 (в массиве `parent`).
- После декодирования каждого символа дерево **обновляется** (функция `sub_1001B0AE`): вес узла инкрементируется, и при нарушении порядка узлы **переставляются** для поддержания свойства.
- При достижении суммарного веса **0x8000 (32768)** — все веса **делятся на 2** (с округлением вверх) и дерево полностью перестраивается.
### Кодирование позиции
Позиция в кольцевом буфере кодируется с помощью **d-кода** (таблица дистанций):
- 8 бит позиции ищутся в таблице `d_code[256]`, определяя базовое значение и количество дополнительных битов.
- Из потока считываются дополнительные биты, которые объединяются с базовым значением.
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`: признак последнего блока
| `rsGetInfo(lib, index, out)` | Получить имя и размер ресурса |
| `rsGetPackMethod(lib, index)` | Получить метод сжатия (`flags & 0x1C0`) |
| `ngiUnpack(packed)` | Декомпрессировать ранее загруженный упакованный ресурс |
| `ngiAlloc(size)` | Выделить память (с обработкой ошибок) |
| `ngiFree(ptr)` | Освободить память |
| `ngiGetMemSize(ptr)` | Получить размер выделенного блока |
---
# Часть 6. Контрольные заметки для реализации
## 6.1. Кодировки и регистр
- **NRes**: имена хранятся **как есть** (case-insensitive при поиске через `_strcmpi`).
- **RsLi**: имена хранятся в **верхнем регистре**. Перед поиском запрос приводится к верхнему регистру (`_strupr`). Сравнение — через `strcmp` (case-sensitive для уже uppercase строк).
## 6.2. Порядок байт
Все значения хранятся в **little-endian** порядке (платформа x86/Win32).
## 6.3. Выравнивание
- **NRes**: данные каждого ресурса выровнены по границе **8 байт** (0-padding между файлами).
- **RsLi**: выравнивание данных не описано в коде (данные идут подряд).
## 6.4. Размер записей на диске
- **NRes**: каталог — **64 байта** на запись, расположен в конце файла.
- **RsLi**: таблица — **32 байта** на запись (зашифрованная), расположена в начале файла (сразу после 32-байтного заголовка).
## 6.5. Кэширование и memory mapping
Оба формата используют Windows Memory-Mapped Files (`CreateFileMapping` + `MapViewOfFile`). NRes-архивы организованы в глобальный **связный список** (`dword_1003A66C`) со счётчиком ссылок и таймером неактивности (10 секунд = 0x2710 мс). При refcount == 0 и истечении таймера архив автоматически выгружается (если не установлен флаг `is_cacheable`).
## 6.6. Размер seed XOR
- **Заголовок RsLi**: seed — **4 байта** (DWORD) по смещению 20, но используются только младшие 2 байта (`lo = byte[0]`, `hi = byte[1]`).