Files
fparkan/docs/specs/assets/nres/overview.md
Valentin Popov 40e7d88fd0
Some checks failed
Test / cargo test (push) Failing after 40s
Add NRes format documentation and decompression algorithms
- 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.
2026-02-05 01:32:24 +04:00

24 KiB
Raw Permalink Blame History

Документация по формату 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.0
  • fileCount: Общее количество файлов в архиве (используется для валидации)
  • 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)

Это означает, что:

  1. dataOffset следующего файла всегда кратен 8
  2. Между данными файлов могут быть 0-7 байт нулевого padding
  3. При чтении нужно использовать 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  // Файл зашифрован/требует декодирования

Алгоритм определения метода:

  1. Извлечь биты packMethod & 0x1E0
  2. Проверить конкретные значения:
    • 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;         // Позиция бита в буфере
    // ... дополнительные поля
};

Процесс декодирования:

  1. Инициализация структуры декодера
  2. Чтение битов и построение дерева Huffman
  3. Декодирование символов по дереву
  4. Запись в выходной буфер

Высокоуровневая инструкция по реализации

Этап 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 представляет собой эффективный архив с поддержкой множества методов сжатия.