Files
fparkan/docs/specs/nres.md
Valentin Popov 615891d550
All checks were successful
Test / Lint (pull_request) Successful in 46s
Test / Test (pull_request) Successful in 48s
Test / Lint (push) Successful in 48s
Test / Test (push) Successful in 49s
feat: обновить заголовки разделов в документации по FXID и NRes для улучшения структуры
2026-02-11 22:10:43 +00:00

45 KiB
Raw Blame History

Форматы игровых ресурсов

Обзор

Библиотека Ngi32.dll реализует два различных формата архивов ресурсов:

  1. NRes — основной формат архива ресурсов, используемый через API niOpenResFile / niCreateResFile. Каталог файлов расположен в конце файла. Поддерживает создание, редактирование, добавление и удаление записей.

  2. RsLi — формат библиотеки ресурсов, используемый через API rsOpenLib / rsLoad. Таблица записей расположена в начале файла (сразу после заголовка) и зашифрована XOR-шифром. Поддерживает несколько методов сжатия. Только чтение.


Часть 1. Формат NRes

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). Если значения не совпадают — файл отклоняется.

1.3. Положение каталога в файле

Каталог располагается в самом конце файла. Его смещение вычисляется по формуле:

directory_offset = total_size - 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 байт)

  • Максимальная длина имени: 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-байтной границе:

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 байт).
  2. Вычисляется total_size = data_end_offset + entry_count × 64.
  3. Индексы сортировки пересчитываются.
  4. Каталог записей записывается в конец файла.

1.7. Режимы сортировки каталога

Функция sub_10012560 поддерживает 12 режимов сортировки (011):

Режим Порядок сортировки
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 — флаги открытия

Второй параметр — битовые флаги:

Бит Маска Описание
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 для предзагрузки данных в оперативную память.


Часть 2. Формат RsLi

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

Пример реализации:

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)

Этот же алгоритм используется для шифрования данных ресурсов с методом XOR (флаги 0x20, 0x60, 0xA0), но с другим начальным ключом из записи.

2.4. Запись таблицы (32 байта, на диске, до дешифровки)

После дешифровки каждая запись имеет следующую структуру:

Смещение Размер Тип Описание
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 (буфер декомпрессии может быть больше)

Методы сжатия (биты 85, маска 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

Дешифровка (псевдокод)

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)    // Диапазон: 04095
length = (high & 0x0F) + 3             // Диапазон: 318

3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80)

Расширенный вариант LZSS, где литералы и длины совпадений кодируются с помощью адаптивного дерева Хаффмана.

Параметры

Параметр Значение
Размер кольцевого буфера 4096 байт
Начальная позиция записи 4036 (0xFC4)
Начальное заполнение 0x20 (пробел)
Количество листовых узлов дерева 314
Символы литералов 0255 (байты)
Символы длин 256313 (длина = символ 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 кодов
    • 0143: 8-битные коды
    • 144255: 9-битные коды
    • 256279: 7-битные коды
    • 280287: 8-битные коды
  • Дистанции: 30 кодов, все 5-битные

Используются предопределённые таблицы длин и дистанций (unk_100370AC, unk_1003712C и соответствующие экстра-биты).

Блок типа 2 (динамические коды)

  1. Прочитать 5 бит → HLIT (количество литералов/длин 257). Диапазон: 257286.
  2. Прочитать 5 бит → HDIST (количество дистанций 1). Диапазон: 130.
  3. Прочитать 4 бита → HCLEN (количество кодов длин 4). Диапазон: 419.
  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)

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

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

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: дополнительные поля
};

Часть 5. Экспортируемые API-функции

5.1. NRes API

Функция Описание
niOpenResFile(path) Открыть NRes-архив (только чтение), эквивалент niOpenResFileEx(path, 0)
niOpenResFileEx(path, flags) Открыть NRes-архив с флагами
niOpenResInMem(ptr, size) Открыть NRes-архив из памяти
niCreateResFile(path) Создать/открыть NRes-архив для записи

5.2. RsLi API

Функция Описание
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) Получить размер выделенного блока

Часть 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]).
  • Запись RsLi: sort_to_original[i] — 2 байта (int16) по смещению 18 записи.
  • Данные при комбинированном XOR+LZSS: seed — 4 байта (DWORD) из поля по смещению 20 записи, но опять используются только 2 байта.

6.7. Эмпирическая проверка на данных игры

  • Найдено архивов по сигнатуре: 122 (NRes: 120, RsLi: 2).
  • Выполнен полный roundtrip unpack -> pack -> byte-compare: 122/122 архивов совпали побайтно.
  • Для RsLi в проверенном наборе встретились методы: 0x040 и 0x100.

Подтверждённые нюансы:

  • Для LZSS (метод 0x040) рабочая раскладка нибблов в ссылке: OOOO LLLL, а не LLLL OOOO.
  • Для Deflate (метод 0x100) возможен случай packed_size == фактический_конец + 1 на последней записи файла.