231 lines
8.1 KiB
Markdown
231 lines
8.1 KiB
Markdown
|
|
# RsLi
|
|||
|
|
|
|||
|
|
`RsLi` — библиотечный контейнер ресурсов движка Parkan: Iron Strategy с зашифрованной таблицей записей и несколькими методами упаковки данных.
|
|||
|
|
|
|||
|
|
Страница описывает формат и runtime-контракт в высокоуровневом виде, без ссылок на внутренние адреса/функции дизассемблера.
|
|||
|
|
|
|||
|
|
Связанная страница:
|
|||
|
|
|
|||
|
|
- [NRes](nres.md)
|
|||
|
|
|
|||
|
|
## 1. Общая структура файла
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
[Header: 32]
|
|||
|
|
[Entry table: entry_count * 32, XOR-encrypted]
|
|||
|
|
[Packed payloads]
|
|||
|
|
[Optional trailer: "AO" + overlay:u32]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
В отличие от `NRes`, таблица записей у `RsLi` расположена в начале файла.
|
|||
|
|
|
|||
|
|
## 2. Заголовок (32 байта)
|
|||
|
|
|
|||
|
|
Все значения little-endian.
|
|||
|
|
|
|||
|
|
| Offset | Size | Type | Поле |
|
|||
|
|
|---:|---:|---|---|
|
|||
|
|
| 0 | 2 | char[2] | `NL` (магия) |
|
|||
|
|
| 2 | 1 | u8 | зарезервировано, в retail = `0` |
|
|||
|
|
| 3 | 1 | u8 | версия, в retail = `1` |
|
|||
|
|
| 4 | 2 | i16 | `entry_count` (должен быть `>= 0`) |
|
|||
|
|
| 14 | 2 | u16 | `presorted_flag` (`0xABBA` = таблица сортировки уже задана) |
|
|||
|
|
| 20 | 4 | u32 | `xor_seed` |
|
|||
|
|
|
|||
|
|
Остальные байты заголовка считаются служебными и должны сохраняться без нормализации.
|
|||
|
|
|
|||
|
|
## 3. Таблица записей (после дешифровки)
|
|||
|
|
|
|||
|
|
Таблица начинается с `offset = 32`, размер `entry_count * 32`.
|
|||
|
|
|
|||
|
|
Каждая запись (32 байта):
|
|||
|
|
|
|||
|
|
| Offset | Size | Type | Поле |
|
|||
|
|
|---:|---:|---|---|
|
|||
|
|
| 0 | 12 | char[12] | `name_raw` (обычно uppercase ASCII, NUL optional) |
|
|||
|
|
| 12 | 4 | bytes | служебный хвост, сохранять как есть |
|
|||
|
|
| 16 | 2 | i16 | `flags` |
|
|||
|
|
| 18 | 2 | i16 | `sort_to_original` |
|
|||
|
|
| 20 | 4 | u32 | `unpacked_size` |
|
|||
|
|
| 24 | 4 | u32 | `data_offset_raw` |
|
|||
|
|
| 28 | 4 | u32 | `packed_size` |
|
|||
|
|
|
|||
|
|
### 3.1. Метод упаковки
|
|||
|
|
|
|||
|
|
`method = flags & 0x1E0`
|
|||
|
|
|
|||
|
|
Поддерживаемые значения:
|
|||
|
|
|
|||
|
|
| Маска | Метод |
|
|||
|
|
|---:|---|
|
|||
|
|
| `0x000` | без сжатия |
|
|||
|
|
| `0x020` | XOR only |
|
|||
|
|
| `0x040` | LZSS |
|
|||
|
|
| `0x060` | XOR + LZSS |
|
|||
|
|
| `0x080` | LZSS + адаптивный Huffman |
|
|||
|
|
| `0x0A0` | XOR + LZSS + адаптивный Huffman |
|
|||
|
|
| `0x100` | raw Deflate (RFC1951) |
|
|||
|
|
|
|||
|
|
Другие значения считаются неподдерживаемыми.
|
|||
|
|
|
|||
|
|
## 4. XOR-дешифрование таблицы и данных
|
|||
|
|
|
|||
|
|
Для таблицы и XOR-методов payload используется один и тот же потоковый XOR-алгоритм.
|
|||
|
|
|
|||
|
|
Ключ:
|
|||
|
|
|
|||
|
|
- `key16 = xor_seed & 0xFFFF` (используются только младшие 16 бит seed).
|
|||
|
|
|
|||
|
|
Состояние:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
lo = key16 & 0xFF
|
|||
|
|
hi = key16 >> 8
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Для каждого байта:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
lo = hi XOR ((lo << 1) mod 256)
|
|||
|
|
out = in XOR lo
|
|||
|
|
hi = lo XOR (hi >> 1)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 5. `sort_to_original` и поиск по имени
|
|||
|
|
|
|||
|
|
### 5.1. Режим `presorted_flag == 0xABBA`
|
|||
|
|
|
|||
|
|
`sort_to_original` обязан быть перестановкой `0..entry_count-1` без дубликатов.
|
|||
|
|
|
|||
|
|
### 5.2. Режим без presorted-флага
|
|||
|
|
|
|||
|
|
Слой загрузки строит `sort_to_original` самостоятельно:
|
|||
|
|
|
|||
|
|
- сортирует индексы по `strcmp`-порядку имен (байтовое сравнение);
|
|||
|
|
- записывает эту перестановку в lookup-таблицу.
|
|||
|
|
|
|||
|
|
### 5.3. Поиск
|
|||
|
|
|
|||
|
|
Поиск выполняется бинарным поиском по lookup-таблице:
|
|||
|
|
|
|||
|
|
1. запрос переводится в uppercase ASCII;
|
|||
|
|
2. на шаге бинарного поиска используется индекс `sort_to_original[mid]`;
|
|||
|
|
3. сравнение имен — bytewise (`strcmp`-логика).
|
|||
|
|
|
|||
|
|
Fail-safe:
|
|||
|
|
|
|||
|
|
- при невалидном индексе lookup-таблицы выполняется линейный fallback.
|
|||
|
|
|
|||
|
|
## 6. AO-трейлер и media overlay
|
|||
|
|
|
|||
|
|
Опциональный трейлер в конце файла:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
"AO" + overlay:u32
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Если трейлер присутствует:
|
|||
|
|
|
|||
|
|
- эффективный offset payload: `effective_offset = data_offset_raw + overlay`.
|
|||
|
|
|
|||
|
|
Ограничение:
|
|||
|
|
|
|||
|
|
- `overlay <= file_size`.
|
|||
|
|
|
|||
|
|
## 7. Декодирование payload по методам
|
|||
|
|
|
|||
|
|
## 7.1. Без сжатия (`0x000`)
|
|||
|
|
|
|||
|
|
Берутся первые `unpacked_size` байт из packed-диапазона.
|
|||
|
|
|
|||
|
|
## 7.2. XOR only (`0x020`)
|
|||
|
|
|
|||
|
|
XOR-дешифрование первых `unpacked_size` байт.
|
|||
|
|
|
|||
|
|
## 7.3. LZSS (`0x040`, `0x060`)
|
|||
|
|
|
|||
|
|
Параметры:
|
|||
|
|
|
|||
|
|
- ring buffer: `4096` байт;
|
|||
|
|
- начальное заполнение ring: `0x20`;
|
|||
|
|
- стартовый указатель ring: `0xFEE`;
|
|||
|
|
- control-биты читаются LSB-first.
|
|||
|
|
|
|||
|
|
Правила:
|
|||
|
|
|
|||
|
|
- `bit=1`: literal byte;
|
|||
|
|
- `bit=0`: ссылка из 2 байт
|
|||
|
|
`offset = low | ((high & 0xF0) << 4)`
|
|||
|
|
`length = (high & 0x0F) + 3`.
|
|||
|
|
|
|||
|
|
Для `0x060` XOR применяется на лету к packed-потоку до LZSS-декодирования.
|
|||
|
|
|
|||
|
|
## 7.4. LZSS + адаптивный Huffman (`0x080`, `0x0A0`)
|
|||
|
|
|
|||
|
|
Параметры:
|
|||
|
|
|
|||
|
|
- `N=4096`, `F=60`, `THRESHOLD=2`;
|
|||
|
|
- адаптивное дерево Huffman обновляется по мере декодирования.
|
|||
|
|
|
|||
|
|
Для `0x0A0` XOR применяется на лету к битовому потоку до Huffman/LZSS-декодирования.
|
|||
|
|
|
|||
|
|
## 7.5. Deflate (`0x100`)
|
|||
|
|
|
|||
|
|
Используется raw Deflate-поток (RFC1951).
|
|||
|
|
|
|||
|
|
Важно:
|
|||
|
|
|
|||
|
|
- zlib-обертка (`RFC1950`) не принимается.
|
|||
|
|
|
|||
|
|
## 8. Quirk: Deflate EOF+1
|
|||
|
|
|
|||
|
|
На retail-корпусе встречается один подтвержденный случай, где:
|
|||
|
|
|
|||
|
|
- `effective_offset + packed_size == file_size + 1`.
|
|||
|
|
|
|||
|
|
Совместимое поведение:
|
|||
|
|
|
|||
|
|
- для метода `0x100` допустить чтение `packed_size - 1` байт (если включен режим совместимости);
|
|||
|
|
- в строгом режиме считать это ошибкой.
|
|||
|
|
|
|||
|
|
## 9. Контрольные инварианты
|
|||
|
|
|
|||
|
|
Минимальные проверки:
|
|||
|
|
|
|||
|
|
1. `magic == "NL"`, `reserved == 0`, `version == 1`.
|
|||
|
|
2. `entry_count >= 0`.
|
|||
|
|
3. `table_end <= file_size`.
|
|||
|
|
4. Если `presorted_flag == 0xABBA`, `sort_to_original` — валидная перестановка.
|
|||
|
|
5. `effective_offset + packed_size` не выходит за EOF (кроме разрешенного deflate EOF+1 quirk).
|
|||
|
|
6. Итоговый распакованный размер равен `unpacked_size`.
|
|||
|
|
|
|||
|
|
## 10. Эмпирическая проверка на retail-корпусе
|
|||
|
|
|
|||
|
|
Проверка на полном наборе `testdata/Parkan - Iron Strategy`:
|
|||
|
|
|
|||
|
|
- обнаружено `2` архива `RsLi`;
|
|||
|
|
- roundtrip `unpack -> repack -> byte-compare`: `2/2` совпали побайтно;
|
|||
|
|
- подтвержден ровно один `deflate EOF+1` случай (`sprites.lib`, entry `23`).
|
|||
|
|
|
|||
|
|
Инструменты:
|
|||
|
|
|
|||
|
|
- `tools/archive_roundtrip_validator.py`
|
|||
|
|
- `crates/rsli` tests
|
|||
|
|
|
|||
|
|
## 11. Статус покрытия и что осталось до 100%
|
|||
|
|
|
|||
|
|
Закрыто:
|
|||
|
|
|
|||
|
|
- формат заголовка/таблицы;
|
|||
|
|
- XOR-алгоритм;
|
|||
|
|
- все используемые методы декодирования;
|
|||
|
|
- AO overlay;
|
|||
|
|
- lookup-поиск и fallback;
|
|||
|
|
- retail-валидация и побайтовый roundtrip.
|
|||
|
|
|
|||
|
|
Осталось до полного 100% архитектурного покрытия движка:
|
|||
|
|
|
|||
|
|
1. Полная функциональная семантика битов `flags` вне маски метода (`0x1E0`) для геймплейных подсистем.
|
|||
|
|
2. Канонический writer для авторинга новых архивов со стабильной стратегией выбора методов (`0x080/0x0A0/0x100`) и параметров компрессии.
|
|||
|
|
3. Формализация поведения для не-ASCII имен (на практике архивы используют ASCII-диапазон).
|