- Created `huffman_decompression.md` detailing the Huffman decompression algorithm used in NRes, including context structure, block modes, and decoding methods. - Created `overview.md` for the NRes format, outlining file structure, header details, file entries, and packing algorithms. - Updated `mkdocs.yml` to include new documentation files in the navigation structure.
24 KiB
Документация по формату NRes
Обзор
NRes — это формат контейнера ресурсов, используемый в игровом движке Nikita. Файл представляет собой архив, содержащий несколько упакованных файлов с метаданными и поддержкой различных методов сжатия.
Структура файла NRes
1. Заголовок файла (16 байт)
struct NResHeader {
uint32_t signature; // +0x00: Сигнатура "NRes" (0x7365526E в little-endian)
uint32_t version; // +0x04: Версия формата (0x00000100 = версия 1.0)
uint32_t fileCount; // +0x08: Количество файлов в архиве
uint32_t fileSize; // +0x0C: Общий размер файла в байтах
};
Детали:
signature: Константа0x7365526E(1936020046 в десятичном виде). Это ASCII строка "nRes" в обратном порядке байтversion: Всегда должна быть0x00000100(256 в десятичном виде) для версии 1.0fileCount: Общее количество файлов в архиве (используется для валидации)fileSize: Полный размер NRes файла, включая заголовок
2. Данные файлов
Сразу после заголовка (с offset 0x10) начинаются данные упакованных файлов. Они хранятся последовательно, один за другим. Точное расположение каждого файла определяется записью в каталоге (см. раздел 3).
⚠️ ВАЖНО: Выравнивание данных
Данные каждого файла выравниваются по границе 8 байт. После записи данных файла добавляется padding (нулевые байты) до ближайшего кратного 8 адреса.
Формула выравнивания:
aligned_size = (packed_size + 7) & ~7
padding_bytes = aligned_size - packed_size
Пример:
- Файл размером 100 байт → padding 4 байта (до 104)
- Файл размером 104 байт → padding 0 байт (уже выровнен)
- Файл размером 105 байт → padding 3 байта (до 108)
Это означает, что:
dataOffsetследующего файла всегда кратен 8- Между данными файлов могут быть 0-7 байт нулевого padding
- При чтении нужно использовать
packedSize, а не выравнивать вручную
3. Каталог файлов (Directory)
Каталог находится в конце файла. Его расположение вычисляется по формуле:
DirectoryOffset = FileSize - (FileCount * 64)
Каждая запись в каталоге имеет фиксированный размер 64 байта (0x40):
struct NResFileEntry {
char name[16]; // +0x00: Имя файла (NULL-terminated, uppercase)
uint32_t crc32; // +0x10: CRC32 хеш упакованных данных
uint32_t packMethod; // +0x14: Флаги метода упаковки и опции
uint32_t unpackedSize; // +0x18: Размер файла после распаковки
uint32_t packedSize; // +0x1C: Размер упакованных данных
uint32_t dataOffset; // +0x20: Смещение данных от начала файла
uint32_t fastDataPtr; // +0x24: Указатель для быстрого доступа (в памяти)
uint32_t xorSize; // +0x28: Размер данных для XOR-шифрования
uint32_t sortIndex; // +0x2C: Индекс для сортировки по имени
uint32_t reserved[4]; // +0x30: Зарезервировано (обычно нули)
};
Подробное описание полей каталога
Поле: name (смещение +0x00, 16 байт)
- Назначение: Имя файла в архиве
- Формат: NULL-terminated строка, максимум 15 символов + NULL
- Особенности:
- Все символы хранятся в UPPERCASE (заглавными буквами)
- При поиске файлов используется регистронезависимое сравнение (
_strcmpi) - Если имя короче 16 байт, остаток заполняется нулями
Поле: crc32 (смещение +0x10, 4 байта)
- Назначение: Контрольная сумма CRC32 упакованных данных
- Использование: Проверка целостности данных при чтении
Поле: packMethod (смещение +0x14, 4 байта)
Критически важное поле! Содержит битовые флаги, определяющие метод обработки данных:
// Маски для извлечения метода упаковки
#define PACK_METHOD_MASK 0x1E0 // Биты 5-8 (основной метод)
#define PACK_METHOD_MASK2 0x1C0 // Биты 6-7 (альтернативная маска)
// Методы упаковки (биты 5-8)
#define PACK_NONE 0x000 // Нет упаковки (копирование)
#define PACK_XOR 0x020 // XOR-шифрование
#define PACK_FRES 0x040 // FRES компрессия (устаревшая)
#define PACK_FRES_XOR 0x060 // FRES + XOR (два прохода)
#define PACK_ZLIB 0x080 // Zlib сжатие (устаревшее)
#define PACK_ZLIB_XOR 0x0A0 // Zlib + XOR (два прохода)
#define PACK_HUFFMAN 0x0E0 // Huffman кодирование (основной метод)
// Дополнительные флаги
#define FLAG_ENCRYPTED 0x040 // Файл зашифрован/требует декодирования
Алгоритм определения метода:
- Извлечь биты
packMethod & 0x1E0 - Проверить конкретные значения:
0x000: Данные не сжаты, простое копирование0x020: XOR-шифрование с двухбайтовым ключом0x040или0x060: FRES компрессия (может быть + XOR)0x080или0x0A0: Zlib компрессия (может быть + XOR)0x0E0: Huffman кодирование (наиболее распространенный)
Поле: unpackedSize (смещение +0x18, 4 байта)
- Назначение: Размер файла после полной распаковки
- Использование:
- Для выделения памяти под распакованные данные
- Для проверки корректности распаковки
Поле: packedSize (смещение +0x1C, 4 байта)
- Назначение: Размер сжатых данных в архиве
- Особенности:
- Если
packedSize == 0, файл пустой или является указателем - Для несжатых файлов:
packedSize == unpackedSize
- Если
Поле: dataOffset (смещение +0x20, 4 байта)
- Назначение: Абсолютное смещение данных файла от начала NRes файла
- Формула вычисления:
BaseAddress + dataOffset = начало данных - Диапазон: Обычно от 0x10 (после заголовка) до начала каталога
Поле: fastDataPtr (смещение +0x24, 4 байта)
- Назначение: Указатель на данные в памяти для быстрого доступа
- Использование: Только во время выполнения (runtime)
- В файле: Обычно равно 0 или содержит относительный offset
- Особенность: Используется функцией
rsLoadFast()для файлов без упаковки
Поле: xorSize (смещение +0x28, 4 байта)
- Назначение: Размер данных для XOR-шифрования при комбинированных методах
- Использование:
- Когда
packMethod & 0x60 == 0x60(FRES + XOR) - Сначала применяется XOR к этому количеству байт, затем FRES к результату
- Когда
- Значение: Может отличаться от
packedSizeпри многоэтапной упаковке
Поле: sortIndex (смещение +0x2C, 4 байта)
- Назначение: Индекс для быстрого поиска по отсортированному каталогу
- Использование:
- Каталог сортируется по алфавиту (имени файлов)
sortIndexхранит оригинальный порядковый номер файла- Позволяет использовать бинарный поиск для функции
rsFind()
Поле: reserved (смещение +0x30, 16 байт)
- Назначение: Зарезервировано для будущих расширений
- В файле: Обычно заполнено нулями
- Может содержать: Дополнительные метаданные в новых версиях формата
Алгоритмы упаковки
1. Без упаковки (PACK_NONE = 0x000)
Простое копирование данных:
memcpy(destination, source, packedSize);
2. XOR-шифрование (PACK_XOR = 0x020)
// Ключ берется из поля crc32
uint16_t key = (uint16_t)(crc32 & 0xFFFF);
for (int i = 0; i < packedSize; i++) {
uint8_t byte = source[i];
destination[i] = byte ^ (key >> 8) ^ (key << 1);
// Обновление ключа
uint8_t newByte = (key >> 8) ^ (key << 1);
key = (newByte ^ ((key >> 8) >> 1)) | (newByte << 8);
}
Ключевые особенности:
- Используется 16-битный ключ из младших байт CRC32
- Ключ изменяется после каждого байта по специальному алгоритму
- Операции: XOR с старшим байтом ключа и со сдвинутым значением
3. FRES компрессия (PACK_FRES = 0x040, 0x060)
Алгоритм FRES — это RLE-подобное сжатие с особой кодировкой повторов:
sub_1001B22E() - функция декомпрессии FRES
- Читает управляющие байты
- Декодирует литералы и повторы
- Использует скользящее окно для ссылок
4. Huffman кодирование (PACK_HUFFMAN = 0x0E0)
Наиболее сложный и эффективный метод:
// Структура декодера
struct HuffmanDecoder {
uint32_t bitBuffer[0x4000]; // Буфер для битов
uint32_t compressedSize; // Размер сжатых данных
uint32_t outputPosition; // Текущая позиция в выходном буфере
uint32_t inputPosition; // Позиция в входных данных
uint8_t* sourceData; // Указатель на сжатые данные
uint8_t* destData; // Указатель на выходной буфер
uint32_t bitPosition; // Позиция бита в буфере
// ... дополнительные поля
};
Процесс декодирования:
- Инициализация структуры декодера
- Чтение битов и построение дерева Huffman
- Декодирование символов по дереву
- Запись в выходной буфер
Высокоуровневая инструкция по реализации
Этап 1: Открытие файла
def open_nres_file(filepath):
with open(filepath, 'rb') as f:
# 1. Читаем заголовок (16 байт)
header_data = f.read(16)
signature, version, file_count, file_size = struct.unpack('<4I', header_data)
# 2. Проверяем сигнатуру
if signature != 0x7365526E: # "nRes"
raise ValueError("Неверная сигнатура файла")
# 3. Проверяем версию
if version != 0x100:
raise ValueError(f"Неподдерживаемая версия: {version}")
# 4. Вычисляем расположение каталога
directory_offset = file_size - (file_count * 64)
# 5. Читаем весь файл в память (или используем memory mapping)
f.seek(0)
file_data = f.read()
return {
'file_count': file_count,
'file_size': file_size,
'directory_offset': directory_offset,
'data': file_data
}
Этап 2: Чтение каталога
def read_directory(nres_file):
data = nres_file['data']
offset = nres_file['directory_offset']
file_count = nres_file['file_count']
entries = []
for i in range(file_count):
entry_offset = offset + (i * 64)
entry_data = data[entry_offset:entry_offset + 64]
# Парсим 64-байтовую запись
name = entry_data[0:16].decode('ascii').rstrip('\x00')
crc32, pack_method, unpacked_size, packed_size, data_offset, \
fast_ptr, xor_size, sort_index = struct.unpack('<8I', entry_data[16:48])
entry = {
'name': name,
'crc32': crc32,
'pack_method': pack_method,
'unpacked_size': unpacked_size,
'packed_size': packed_size,
'data_offset': data_offset,
'fast_data_ptr': fast_ptr,
'xor_size': xor_size,
'sort_index': sort_index
}
entries.append(entry)
return entries
Этап 3: Поиск файла по имени
def find_file(entries, filename):
# Имена в архиве хранятся в UPPERCASE
search_name = filename.upper()
# Используем бинарный поиск, так как каталог отсортирован
# Сортировка по sort_index восстанавливает алфавитный порядок
sorted_entries = sorted(entries, key=lambda e: e['sort_index'])
left, right = 0, len(sorted_entries) - 1
while left <= right:
mid = (left + right) // 2
mid_name = sorted_entries[mid]['name']
if mid_name == search_name:
return sorted_entries[mid]
elif mid_name < search_name:
left = mid + 1
else:
right = mid - 1
return None
Этап 4: Извлечение данных файла
def extract_file(nres_file, entry):
data = nres_file['data']
# 1. Получаем упакованные данные
packed_data = data[entry['data_offset']:
entry['data_offset'] + entry['packed_size']]
# 2. Определяем метод упаковки
pack_method = entry['pack_method'] & 0x1E0
# 3. Распаковываем в зависимости от метода
if pack_method == 0x000:
# Без упаковки
return unpack_none(packed_data)
elif pack_method == 0x020:
# XOR-шифрование
return unpack_xor(packed_data, entry['crc32'], entry['unpacked_size'])
elif pack_method == 0x040 or pack_method == 0x060:
# FRES компрессия (может быть с XOR)
if pack_method == 0x060:
# Сначала XOR
temp_data = unpack_xor(packed_data, entry['crc32'], entry['xor_size'])
return unpack_fres(temp_data, entry['unpacked_size'])
else:
return unpack_fres(packed_data, entry['unpacked_size'])
elif pack_method == 0x0E0:
# Huffman кодирование
return unpack_huffman(packed_data, entry['unpacked_size'])
else:
raise ValueError(f"Неподдерживаемый метод упаковки: 0x{pack_method:X}")
Этап 5: Реализация алгоритмов распаковки
def unpack_none(data):
"""Без упаковки - просто возвращаем данные"""
return data
def unpack_xor(data, crc32, size):
"""XOR-дешифрование с изменяющимся ключом"""
result = bytearray(size)
key = crc32 & 0xFFFF # Берем младшие 16 бит
for i in range(min(size, len(data))):
byte = data[i]
# XOR операция
high_byte = (key >> 8) & 0xFF
shifted = (key << 1) & 0xFFFF
result[i] = byte ^ high_byte ^ (shifted & 0xFF)
# Обновление ключа
new_byte = high_byte ^ (key << 1)
key = (new_byte ^ (high_byte >> 1)) | ((new_byte & 0xFF) << 8)
key &= 0xFFFF
return bytes(result)
def unpack_fres(data, unpacked_size):
"""
FRES декомпрессия - гибридный RLE+LZ77 алгоритм
Полная реализация в nres_decompression.py (класс FRESDecoder)
"""
from nres_decompression import FRESDecoder
decoder = FRESDecoder()
return decoder.decompress(data, unpacked_size)
def unpack_huffman(data, unpacked_size):
"""
Huffman декодирование (DEFLATE-подобный)
Полная реализация в nres_decompression.py (класс HuffmanDecoder)
"""
from nres_decompression import HuffmanDecoder
decoder = HuffmanDecoder()
return decoder.decompress(data, unpacked_size)
Этап 6: Извлечение всех файлов
def extract_all(nres_filepath, output_dir):
import os
# 1. Открываем NRes файл
nres_file = open_nres_file(nres_filepath)
# 2. Читаем каталог
entries = read_directory(nres_file)
# 3. Создаем выходную директорию
os.makedirs(output_dir, exist_ok=True)
# 4. Извлекаем каждый файл
for entry in entries:
print(f"Извлечение: {entry['name']}")
try:
# Извлекаем данные
unpacked_data = extract_file(nres_file, entry)
# Сохраняем в файл
output_path = os.path.join(output_dir, entry['name'])
with open(output_path, 'wb') as f:
f.write(unpacked_data)
print(f" ✓ Успешно ({len(unpacked_data)} байт)")
except Exception as e:
print(f" ✗ Ошибка: {e}")
Особенности и важные замечания
1. Порядок байт (Endianness)
- Все многобайтовые значения хранятся в Little-Endian порядке
- При чтении используйте
struct.unpack('<...')
2. Сортировка каталога
- Каталог файлов отсортирован по имени файла (алфавитный порядок)
- Поле
sortIndexхранит оригинальный индекс до сортировки - Это позволяет использовать бинарный поиск
3. Регистр символов
- Все имена файлов конвертируются в UPPERCASE (заглавные буквы)
- При поиске используйте регистронезависимое сравнение
4. Memory Mapping
- Оригинальный код использует
MapViewOfFileдля эффективной работы с большими файлами - Рекомендуется использовать memory-mapped файлы для больших архивов
5. Валидация данных
- Всегда проверяйте сигнатуру перед обработкой
- Проверяйте версию формата
- Проверяйте CRC32 после распаковки
- Проверяйте размеры (unpacked_size должен совпадать с результатом)
6. Обработка ошибок
- Файл может быть поврежден
- Метод упаковки может быть неподдерживаемым
- Данные могут быть частично зашифрованы
7. Производительность
- Для несжатых файлов (
packMethod & 0x1E0 == 0) можно использовать прямое чтение - Поле
fastDataPtrможет содержать кешированный указатель - Используйте буферизацию при последовательном чтении
8. Выравнивание данных
- Все данные файлов выравниваются по 8 байт
- После каждого файла может быть 0-7 байт нулевого padding
dataOffsetследующего файла всегда кратен 8- При чтении используйте
packedSizeиз записи, не вычисляйте выравнивание - При создании архива добавляйте padding:
padding = ((size + 7) & ~7) - size
Пример использования
# Открыть архив
nres = open_nres_file("resources.nres")
# Прочитать каталог
entries = read_directory(nres)
# Вывести список файлов
for entry in entries:
print(f"{entry['name']:20s} - {entry['unpacked_size']:8d} байт")
# Найти конкретный файл
entry = find_file(entries, "texture.bmp")
if entry:
data = extract_file(nres, entry)
with open("extracted_texture.bmp", "wb") as f:
f.write(data)
# Извлечь все файлы
extract_all("resources.nres", "./extracted/")
Дополнительные функции
Проверка формата файла
def is_nres_file(filepath):
try:
with open(filepath, 'rb') as f:
signature = struct.unpack('<I', f.read(4))[0]
return signature == 0x7365526E
except:
return False
Получение информации о файле
def get_file_info(entry):
pack_names = {
0x000: "Без сжатия",
0x020: "XOR",
0x040: "FRES",
0x060: "FRES+XOR",
0x080: "Zlib",
0x0A0: "Zlib+XOR",
0x0E0: "Huffman"
}
pack_method = entry['pack_method'] & 0x1E0
pack_name = pack_names.get(pack_method, f"Неизвестный (0x{pack_method:X})")
ratio = 100.0 * entry['packed_size'] / entry['unpacked_size'] if entry['unpacked_size'] > 0 else 0
return {
'name': entry['name'],
'size': entry['unpacked_size'],
'packed': entry['packed_size'],
'compression': pack_name,
'ratio': f"{ratio:.1f}%",
'crc32': f"0x{entry['crc32']:08X}"
}
Заключение
Формат NRes представляет собой эффективный архив с поддержкой множества методов сжатия.