- Updated .gitignore to include common Python artifacts and project-specific files. - Added `archive_roundtrip_validator.py` script for validating NRes and RsLi formats against real game data. - Created README.md for the tools directory, detailing usage and supported signatures. - Enhanced nres.md with practical nuances and empirical checks for game data.
45 KiB
Форматы игровых ресурсов
Обзор
Библиотека Ngi32.dll реализует два различных формата архивов ресурсов:
-
NRes — основной формат архива ресурсов, используемый через API
niOpenResFile/niCreateResFile. Каталог файлов расположен в конце файла. Поддерживает создание, редактирование, добавление и удаление записей. -
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)
При создании нового файла:
- Если файл уже существует и содержит корректный NRes-архив, существующий каталог считывается с конца файла, а файл усекается до начала каталога.
- Если файл пуст или не является NRes-архивом, создаётся новый с пустым каталогом. Поля
entry_count = 0,total_size = 16.
При закрытии файла (sub_100122D0):
- Заголовок переписывается в начало файла (16 байт).
- Вычисляется
total_size = data_end_offset + entry_count × 64. - Индексы сортировки пересчитываются.
- Каталог записей записывается в конец файла.
1.7. Режимы сортировки каталога
Функция sub_10012560 поддерживает 12 режимов сортировки (0–11):
| Режим | Порядок сортировки |
|---|---|
| 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 (буфер декомпрессии может быть больше)
Методы сжатия (биты 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
Дешифровка (псевдокод)
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-декомпрессия.
Алгоритм
- Выделить временный буфер размером
compressed_size(поле из записи, смещение 28). - Дешифровать сжатые данные XOR-шифром (раздел 3.1) с ключом из записи во временный буфер.
- Применить LZSS-декомпрессию (простую или с Хаффманом, в зависимости от конкретного метода) из временного буфера в выходной.
- Освободить временный буфер.
- 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)
- Отбросить оставшиеся биты до границы байта (выравнивание).
- Прочитать 16 бит —
LEN(длина блока). - Прочитать 16 бит —
NLEN(дополнение длины,NLEN == ~LEN & 0xFFFF). - Проверить:
LEN == (uint16)(~NLEN). При несовпадении — ошибка. - Скопировать
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 (динамические коды)
- Прочитать 5 бит →
HLIT(количество литералов/длин − 257). Диапазон: 257–286. - Прочитать 5 бит →
HDIST(количество дистанций − 1). Диапазон: 1–30. - Прочитать 4 бита →
HCLEN(количество кодов длин − 4). Диапазон: 4–19. - Прочитать
HCLEN× 3 бит — длины кодов для алфавита длин. - Построить дерево Хаффмана для алфавита длин (19 символов).
- С помощью этого дерева декодировать длины кодов для литералов/длин и дистанций.
- Построить два дерева Хаффмана: для литералов/длин и для дистанций.
- Декодировать данные.
Порядок кодов длин (стандартный 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на последней записи файла.