Compare commits
32 Commits
ef93237724
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c4f542fc2 | |||
| 4c9d772b03 | |||
| 097a915f35 | |||
|
c691de0dd0
|
|||
|
92818ce0c4
|
|||
|
6676cfdd8d
|
|||
|
8b639ee6c9
|
|||
|
a58dea5499
|
|||
|
615891d550
|
|||
|
481ff1c06d
|
|||
|
7702d800a0
|
|||
|
3c06e768d6
|
|||
|
70ed6480c2
|
|||
|
662b292b5b
|
|||
|
3410b54793
|
|||
|
041b1a6cb3
|
|||
|
5035d02220
|
|||
|
ba1789f106
|
|||
|
842f4a8569
|
|||
|
ce6e30f727
|
|||
|
4af183ad74
|
|||
|
ab413bd751
|
|||
|
b5e6fad3c3
|
|||
|
c69cad6a26
|
|||
|
a24910791e
|
|||
|
371a060eb6
|
|||
|
e08b5f3853
|
|||
|
5a97f2e429
|
|||
|
9e2dcb44a6
|
|||
|
828106ba81
|
|||
|
a7dd18fa1d
|
|||
|
f8cca32968
|
9
.devcontainer/devcontainer.json
Normal file
9
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/rust:latest",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": ["rust-lang.rust-analyzer"]
|
||||
}
|
||||
},
|
||||
"runArgs": ["--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined"]
|
||||
}
|
||||
48
.gitea/workflows/docs-deploy.yml
Normal file
48
.gitea/workflows/docs-deploy.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Docs Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
deploy-docs:
|
||||
name: Build and Deploy MkDocs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Install docs dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Build MkDocs site
|
||||
run: mkdocs build
|
||||
|
||||
- name: Install rsync
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y rsync openssh-client
|
||||
|
||||
- name: Prepare SSH key
|
||||
env:
|
||||
SSH_KEY_B64: ${{ secrets.ROOT_CI_KEY_B64 }}
|
||||
run: |
|
||||
umask 077
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s' "$SSH_KEY_B64" | base64 -d > ~/.ssh/id_root_ci
|
||||
chmod 600 ~/.ssh/id_root_ci
|
||||
|
||||
- name: Deploy via rsync
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.FPARKAN_DEPLOY_HOST }}
|
||||
DEPLOY_PORT: ${{ secrets.FPARKAN_DEPLOY_PORT }}
|
||||
run: |
|
||||
rsync -rlz --delete \
|
||||
-e "ssh -p ${DEPLOY_PORT} -i ~/.ssh/id_root_ci -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
|
||||
site/ "gitea-runner@${DEPLOY_HOST}:./"
|
||||
@@ -3,9 +3,6 @@ name: RenovateBot
|
||||
on:
|
||||
schedule:
|
||||
- cron: "@daily"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
|
||||
@@ -3,11 +3,25 @@ name: Test
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: cargo test
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo check --all
|
||||
- run: cargo test --all-features
|
||||
with:
|
||||
components: clippy
|
||||
- name: Cargo check
|
||||
run: cargo check --workspace --all-targets --all-features
|
||||
- name: Clippy (deny warnings)
|
||||
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Cargo test
|
||||
run: cargo test --workspace --all-features -- --nocapture
|
||||
|
||||
14
.github/dependabot.yml
vendored
14
.github/dependabot.yml
vendored
@@ -1,14 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "devcontainers"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
8
Cargo.toml
Normal file
8
Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*"]
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
strip = true
|
||||
60
README.md
60
README.md
@@ -1,11 +1,55 @@
|
||||
# Utilities for the game "Parkan: Iron Strategy"
|
||||
# FParkan
|
||||
|
||||
This repository contains utilities, tools, and libraries for the game "Parkan: Iron Strategy."
|
||||
Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»** и набором [вспомогательных инструментов](tools) для исследования.
|
||||
|
||||
## List of projects
|
||||
## Описание
|
||||
|
||||
- [unpacker](unpacker): Text-based utility for unpacking game resources in the NRres format. Allows unpacking 100% of game resources.
|
||||
- [packer](packer): Text-based utility for packing game resources in the NRres format. Allows packing 100% of game resources.
|
||||
- [texture-decoder](texture-decoder): (WIP) Decoder for game textures. Decodes approximately 20% of game textures.
|
||||
- [libnres](libnres): _(Deprecation)_ Library for NRes files.
|
||||
- [nres-cli](nres-cli): _(Deprecation)_ Console tool for NRes files.
|
||||
Проект находится в активной разработке и включает:
|
||||
|
||||
- библиотеки для работы с форматами игровых архивов;
|
||||
- инструменты для валидации/подготовки тестовых данных;
|
||||
- спецификации форматов и сопутствующую документацию.
|
||||
|
||||
## Установка
|
||||
|
||||
Проект находится в начальной стадии, подробная инструкция по установке пока отсутствует.
|
||||
|
||||
## Документация
|
||||
|
||||
- локально: каталог [`docs/`](docs)
|
||||
- сайт: <https://fparkan.popov.link>
|
||||
|
||||
## Инструменты
|
||||
|
||||
Вспомогательные инструменты находятся в каталоге [`tools/`](tools).
|
||||
|
||||
- [tools/archive_roundtrip_validator.py](tools/archive_roundtrip_validator.py) — инструмент верификации документации по архивам `NRes`/`RsLi` на реальных файлах (включая `unpack -> repack -> byte-compare`).
|
||||
- [tools/init_testdata.py](tools/init_testdata.py) — подготовка тестовых данных по сигнатурам с раскладкой по каталогам.
|
||||
|
||||
## Библиотеки
|
||||
|
||||
- [crates/nres](crates/nres) — библиотека для работы с файлами архивов NRes (чтение, поиск, редактирование, сохранение).
|
||||
- [crates/rsli](crates/rsli) — библиотека для работы с файлами архивов RsLi (чтение, поиск, загрузка/распаковка поддерживаемых методов).
|
||||
|
||||
## Тестирование
|
||||
|
||||
Базовое тестирование проходит на синтетических тестах из репозитория.
|
||||
|
||||
Для дополнительного тестирования на реальных игровых ресурсах:
|
||||
|
||||
- используйте [tools/init_testdata.py](tools/init_testdata.py) для подготовки локального набора;
|
||||
- используйте оригинальную копию игры (диск или [GOG-версия](https://www.gog.com/en/game/parkan_iron_strategy));
|
||||
- игровые ресурсы в репозиторий не включаются, так как защищены авторским правом.
|
||||
|
||||
## Contributing & Support
|
||||
|
||||
Проект активно поддерживается и открыт для contribution. Issues и pull requests можно создавать в обоих репозиториях:
|
||||
|
||||
- **Primary development**: [valentineus/fparkan](https://code.popov.link/valentineus/fparkan)
|
||||
- **GitHub mirror**: [valentineus/fparkan](https://github.com/valentineus/fparkan)
|
||||
|
||||
Основная разработка ведётся в self-hosted репозитории.
|
||||
|
||||
## Лицензия
|
||||
|
||||
Проект распространяется под лицензией **[GNU GPL v2](LICENSE.txt)**.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
_ = b; // stub
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
.{
|
||||
.name = .fparkan,
|
||||
.version = "0.0.1",
|
||||
.minimum_zig_version = "0.15.2",
|
||||
.paths = .{""},
|
||||
.fingerprint = 0x8922aff25cf1dd39,
|
||||
}
|
||||
6
crates/common/Cargo.toml
Normal file
6
crates/common/Cargo.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "common"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
44
crates/common/src/lib.rs
Normal file
44
crates/common/src/lib.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use std::io;
|
||||
|
||||
/// Resource payload that can be either borrowed from mapped bytes or owned.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ResourceData<'a> {
|
||||
Borrowed(&'a [u8]),
|
||||
Owned(Vec<u8>),
|
||||
}
|
||||
|
||||
impl<'a> ResourceData<'a> {
|
||||
pub fn as_slice(&self) -> &[u8] {
|
||||
match self {
|
||||
Self::Borrowed(slice) => slice,
|
||||
Self::Owned(buf) => buf.as_slice(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_owned(self) -> Vec<u8> {
|
||||
match self {
|
||||
Self::Borrowed(slice) => slice.to_vec(),
|
||||
Self::Owned(buf) => buf,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<[u8]> for ResourceData<'_> {
|
||||
fn as_ref(&self) -> &[u8] {
|
||||
self.as_slice()
|
||||
}
|
||||
}
|
||||
|
||||
/// Output sink used by `read_into`/`load_into` APIs.
|
||||
pub trait OutputBuffer {
|
||||
/// Writes the full payload to the sink, replacing any previous content.
|
||||
fn write_exact(&mut self, data: &[u8]) -> io::Result<()>;
|
||||
}
|
||||
|
||||
impl OutputBuffer for Vec<u8> {
|
||||
fn write_exact(&mut self, data: &[u8]) -> io::Result<()> {
|
||||
self.clear();
|
||||
self.extend_from_slice(data);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
10
crates/nres/Cargo.toml
Normal file
10
crates/nres/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "nres"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
|
||||
42
crates/nres/README.md
Normal file
42
crates/nres/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# nres
|
||||
|
||||
Rust-библиотека для работы с архивами формата **NRes**.
|
||||
|
||||
## Что умеет
|
||||
|
||||
- Открытие архива из файла (`open_path`) и из памяти (`open_bytes`).
|
||||
- Поддержка `raw_mode` (весь файл как единый ресурс).
|
||||
- Чтение метаданных и итерация по записям.
|
||||
- Поиск по имени без учёта регистра (`find`).
|
||||
- Чтение данных ресурса (`read`, `read_into`, `raw_slice`).
|
||||
- Редактирование архива через `Editor`:
|
||||
- `add`, `replace_data`, `remove`.
|
||||
- `commit` с пересчётом `sort_index`, выравниванием по 8 байт и атомарной записью файла.
|
||||
|
||||
## Модель ошибок
|
||||
|
||||
Библиотека возвращает типизированные ошибки (`InvalidMagic`, `UnsupportedVersion`, `TotalSizeMismatch`, `DirectoryOutOfBounds`, `EntryDataOutOfBounds`, и др.) без паник в production-коде.
|
||||
|
||||
## Покрытие тестами
|
||||
|
||||
### Реальные файлы
|
||||
|
||||
- Рекурсивный прогон по `testdata/nres/**`.
|
||||
- Сейчас в наборе: **120 архивов**.
|
||||
- Для каждого архива проверяется:
|
||||
- чтение всех записей;
|
||||
- `read`/`read_into`/`raw_slice`;
|
||||
- `find`;
|
||||
- `unpack -> repack (Editor::commit)` с проверкой **byte-to-byte**.
|
||||
|
||||
### Синтетические тесты
|
||||
|
||||
- Проверка основных сценариев редактирования (`add/replace/remove/commit`).
|
||||
- Проверка валидации и ошибок:
|
||||
- `InvalidMagic`, `UnsupportedVersion`, `TotalSizeMismatch`, `InvalidEntryCount`, `DirectoryOutOfBounds`, `NameTooLong`, `EntryDataOutOfBounds`, `EntryIdOutOfRange`, `NameContainsNul`.
|
||||
|
||||
## Быстрый запуск тестов
|
||||
|
||||
```bash
|
||||
cargo test -p nres -- --nocapture
|
||||
```
|
||||
110
crates/nres/src/error.rs
Normal file
110
crates/nres/src/error.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use core::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
Io(std::io::Error),
|
||||
|
||||
InvalidMagic {
|
||||
got: [u8; 4],
|
||||
},
|
||||
UnsupportedVersion {
|
||||
got: u32,
|
||||
},
|
||||
TotalSizeMismatch {
|
||||
header: u32,
|
||||
actual: u64,
|
||||
},
|
||||
|
||||
InvalidEntryCount {
|
||||
got: i32,
|
||||
},
|
||||
TooManyEntries {
|
||||
got: usize,
|
||||
},
|
||||
DirectoryOutOfBounds {
|
||||
directory_offset: u64,
|
||||
directory_len: u64,
|
||||
file_len: u64,
|
||||
},
|
||||
|
||||
EntryIdOutOfRange {
|
||||
id: u32,
|
||||
entry_count: u32,
|
||||
},
|
||||
EntryDataOutOfBounds {
|
||||
id: u32,
|
||||
offset: u64,
|
||||
size: u32,
|
||||
directory_offset: u64,
|
||||
},
|
||||
NameTooLong {
|
||||
got: usize,
|
||||
max: usize,
|
||||
},
|
||||
NameContainsNul,
|
||||
BadNameEncoding,
|
||||
|
||||
IntegerOverflow,
|
||||
|
||||
RawModeDisallowsOperation(&'static str),
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::Io(e) => write!(f, "I/O error: {e}"),
|
||||
Error::InvalidMagic { got } => write!(f, "invalid NRes magic: {got:02X?}"),
|
||||
Error::UnsupportedVersion { got } => {
|
||||
write!(f, "unsupported NRes version: {got:#x}")
|
||||
}
|
||||
Error::TotalSizeMismatch { header, actual } => {
|
||||
write!(f, "NRes total_size mismatch: header={header}, actual={actual}")
|
||||
}
|
||||
Error::InvalidEntryCount { got } => write!(f, "invalid entry_count: {got}"),
|
||||
Error::TooManyEntries { got } => write!(f, "too many entries: {got} exceeds u32::MAX"),
|
||||
Error::DirectoryOutOfBounds {
|
||||
directory_offset,
|
||||
directory_len,
|
||||
file_len,
|
||||
} => write!(
|
||||
f,
|
||||
"directory out of bounds: off={directory_offset}, len={directory_len}, file={file_len}"
|
||||
),
|
||||
Error::EntryIdOutOfRange { id, entry_count } => {
|
||||
write!(f, "entry id out of range: id={id}, count={entry_count}")
|
||||
}
|
||||
Error::EntryDataOutOfBounds {
|
||||
id,
|
||||
offset,
|
||||
size,
|
||||
directory_offset,
|
||||
} => write!(
|
||||
f,
|
||||
"entry data out of bounds: id={id}, off={offset}, size={size}, dir_off={directory_offset}"
|
||||
),
|
||||
Error::NameTooLong { got, max } => write!(f, "name too long: {got} > {max}"),
|
||||
Error::NameContainsNul => write!(f, "name contains NUL byte"),
|
||||
Error::BadNameEncoding => write!(f, "bad name encoding"),
|
||||
Error::IntegerOverflow => write!(f, "integer overflow"),
|
||||
Error::RawModeDisallowsOperation(op) => {
|
||||
write!(f, "operation not allowed in raw mode: {op}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
702
crates/nres/src/lib.rs
Normal file
702
crates/nres/src/lib.rs
Normal file
@@ -0,0 +1,702 @@
|
||||
pub mod error;
|
||||
|
||||
use crate::error::Error;
|
||||
use common::{OutputBuffer, ResourceData};
|
||||
use core::ops::Range;
|
||||
use std::cmp::Ordering;
|
||||
use std::fs::{self, OpenOptions as FsOpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct OpenOptions {
|
||||
pub raw_mode: bool,
|
||||
pub sequential_hint: bool,
|
||||
pub prefetch_pages: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub enum OpenMode {
|
||||
#[default]
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Archive {
|
||||
bytes: Arc<[u8]>,
|
||||
entries: Vec<EntryRecord>,
|
||||
raw_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct EntryId(pub u32);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EntryMeta {
|
||||
pub kind: u32,
|
||||
pub attr1: u32,
|
||||
pub attr2: u32,
|
||||
pub attr3: u32,
|
||||
pub name: String,
|
||||
pub data_offset: u64,
|
||||
pub data_size: u32,
|
||||
pub sort_index: u32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct EntryRef<'a> {
|
||||
pub id: EntryId,
|
||||
pub meta: &'a EntryMeta,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct EntryRecord {
|
||||
meta: EntryMeta,
|
||||
name_raw: [u8; 36],
|
||||
}
|
||||
|
||||
impl Archive {
|
||||
pub fn open_path(path: impl AsRef<Path>) -> Result<Self> {
|
||||
Self::open_path_with(path, OpenMode::ReadOnly, OpenOptions::default())
|
||||
}
|
||||
|
||||
pub fn open_path_with(
|
||||
path: impl AsRef<Path>,
|
||||
_mode: OpenMode,
|
||||
opts: OpenOptions,
|
||||
) -> Result<Self> {
|
||||
let bytes = fs::read(path.as_ref())?;
|
||||
let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice());
|
||||
Self::open_bytes(arc, opts)
|
||||
}
|
||||
|
||||
pub fn open_bytes(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Self> {
|
||||
let (entries, _) = parse_archive(&bytes, opts.raw_mode)?;
|
||||
if opts.prefetch_pages {
|
||||
prefetch_pages(&bytes);
|
||||
}
|
||||
Ok(Self {
|
||||
bytes,
|
||||
entries,
|
||||
raw_mode: opts.raw_mode,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
||||
self.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, entry)| EntryRef {
|
||||
id: EntryId(u32::try_from(idx).expect("entry count validated at parse")),
|
||||
meta: &entry.meta,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find(&self, name: &str) -> Option<EntryId> {
|
||||
if self.entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !self.raw_mode {
|
||||
let mut low = 0usize;
|
||||
let mut high = self.entries.len();
|
||||
while low < high {
|
||||
let mid = low + (high - low) / 2;
|
||||
let Ok(target_idx) = usize::try_from(self.entries[mid].meta.sort_index) else {
|
||||
break;
|
||||
};
|
||||
if target_idx >= self.entries.len() {
|
||||
break;
|
||||
}
|
||||
let cmp = cmp_name_case_insensitive(
|
||||
name.as_bytes(),
|
||||
entry_name_bytes(&self.entries[target_idx].name_raw),
|
||||
);
|
||||
match cmp {
|
||||
Ordering::Less => high = mid,
|
||||
Ordering::Greater => low = mid + 1,
|
||||
Ordering::Equal => {
|
||||
return Some(EntryId(
|
||||
u32::try_from(target_idx).expect("entry count validated at parse"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.entries.iter().enumerate().find_map(|(idx, entry)| {
|
||||
if cmp_name_case_insensitive(name.as_bytes(), entry_name_bytes(&entry.name_raw))
|
||||
== Ordering::Equal
|
||||
{
|
||||
Some(EntryId(
|
||||
u32::try_from(idx).expect("entry count validated at parse"),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(&self, id: EntryId) -> Option<EntryRef<'_>> {
|
||||
let idx = usize::try_from(id.0).ok()?;
|
||||
let entry = self.entries.get(idx)?;
|
||||
Some(EntryRef {
|
||||
id,
|
||||
meta: &entry.meta,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read(&self, id: EntryId) -> Result<ResourceData<'_>> {
|
||||
let range = self.entry_range(id)?;
|
||||
Ok(ResourceData::Borrowed(&self.bytes[range]))
|
||||
}
|
||||
|
||||
pub fn read_into(&self, id: EntryId, out: &mut dyn OutputBuffer) -> Result<usize> {
|
||||
let range = self.entry_range(id)?;
|
||||
out.write_exact(&self.bytes[range.clone()])?;
|
||||
Ok(range.len())
|
||||
}
|
||||
|
||||
pub fn raw_slice(&self, id: EntryId) -> Result<Option<&[u8]>> {
|
||||
let range = self.entry_range(id)?;
|
||||
Ok(Some(&self.bytes[range]))
|
||||
}
|
||||
|
||||
pub fn edit_path(path: impl AsRef<Path>) -> Result<Editor> {
|
||||
let path_buf = path.as_ref().to_path_buf();
|
||||
let bytes = fs::read(&path_buf)?;
|
||||
let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice());
|
||||
let (entries, _) = parse_archive(&arc, false)?;
|
||||
let mut editable = Vec::with_capacity(entries.len());
|
||||
for entry in &entries {
|
||||
let range = checked_range(entry.meta.data_offset, entry.meta.data_size, arc.len())?;
|
||||
editable.push(EditableEntry {
|
||||
meta: entry.meta.clone(),
|
||||
name_raw: entry.name_raw,
|
||||
data: EntryData::Borrowed(range), // Copy-on-write: only store range
|
||||
});
|
||||
}
|
||||
Ok(Editor {
|
||||
path: path_buf,
|
||||
source: arc,
|
||||
entries: editable,
|
||||
})
|
||||
}
|
||||
|
||||
fn entry_range(&self, id: EntryId) -> Result<Range<usize>> {
|
||||
let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
|
||||
let Some(entry) = self.entries.get(idx) else {
|
||||
return Err(Error::EntryIdOutOfRange {
|
||||
id: id.0,
|
||||
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX),
|
||||
});
|
||||
};
|
||||
checked_range(
|
||||
entry.meta.data_offset,
|
||||
entry.meta.data_size,
|
||||
self.bytes.len(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Editor {
|
||||
path: PathBuf,
|
||||
source: Arc<[u8]>,
|
||||
entries: Vec<EditableEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum EntryData {
|
||||
Borrowed(Range<usize>),
|
||||
Modified(Vec<u8>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct EditableEntry {
|
||||
meta: EntryMeta,
|
||||
name_raw: [u8; 36],
|
||||
data: EntryData,
|
||||
}
|
||||
|
||||
impl EditableEntry {
|
||||
fn data_slice<'a>(&'a self, source: &'a Arc<[u8]>) -> &'a [u8] {
|
||||
match &self.data {
|
||||
EntryData::Borrowed(range) => &source[range.clone()],
|
||||
EntryData::Modified(vec) => vec.as_slice(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NewEntry<'a> {
|
||||
pub kind: u32,
|
||||
pub attr1: u32,
|
||||
pub attr2: u32,
|
||||
pub attr3: u32,
|
||||
pub name: &'a str,
|
||||
pub data: &'a [u8],
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
||||
self.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, entry)| EntryRef {
|
||||
id: EntryId(u32::try_from(idx).expect("entry count validated at add")),
|
||||
meta: &entry.meta,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add(&mut self, entry: NewEntry<'_>) -> Result<EntryId> {
|
||||
let name_raw = encode_name_field(entry.name)?;
|
||||
let id_u32 = u32::try_from(self.entries.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
let data_size = u32::try_from(entry.data.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
self.entries.push(EditableEntry {
|
||||
meta: EntryMeta {
|
||||
kind: entry.kind,
|
||||
attr1: entry.attr1,
|
||||
attr2: entry.attr2,
|
||||
attr3: entry.attr3,
|
||||
name: decode_name(entry_name_bytes(&name_raw)),
|
||||
data_offset: 0,
|
||||
data_size,
|
||||
sort_index: 0,
|
||||
},
|
||||
name_raw,
|
||||
data: EntryData::Modified(entry.data.to_vec()),
|
||||
});
|
||||
Ok(EntryId(id_u32))
|
||||
}
|
||||
|
||||
pub fn replace_data(&mut self, id: EntryId, data: &[u8]) -> Result<()> {
|
||||
let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
|
||||
let Some(entry) = self.entries.get_mut(idx) else {
|
||||
return Err(Error::EntryIdOutOfRange {
|
||||
id: id.0,
|
||||
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX),
|
||||
});
|
||||
};
|
||||
entry.meta.data_size = u32::try_from(data.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
// Replace with new data (triggers copy-on-write if borrowed)
|
||||
entry.data = EntryData::Modified(data.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: EntryId) -> Result<()> {
|
||||
let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
|
||||
if idx >= self.entries.len() {
|
||||
return Err(Error::EntryIdOutOfRange {
|
||||
id: id.0,
|
||||
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX),
|
||||
});
|
||||
}
|
||||
self.entries.remove(idx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn commit(mut self) -> Result<()> {
|
||||
let count_u32 = u32::try_from(self.entries.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
|
||||
// Pre-calculate capacity to avoid reallocations
|
||||
let total_data_size: usize = self
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| e.data_slice(&self.source).len())
|
||||
.sum();
|
||||
let padding_estimate = self.entries.len() * 8; // Max 8 bytes padding per entry
|
||||
let directory_size = self.entries.len() * 64; // 64 bytes per entry
|
||||
let capacity = 16 + total_data_size + padding_estimate + directory_size;
|
||||
|
||||
let mut out = Vec::with_capacity(capacity);
|
||||
out.resize(16, 0); // Header
|
||||
|
||||
// Keep reference to source for copy-on-write
|
||||
let source = &self.source;
|
||||
|
||||
for entry in &mut self.entries {
|
||||
entry.meta.data_offset =
|
||||
u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
|
||||
// Calculate size and get slice separately to avoid borrow conflicts
|
||||
let data_len = entry.data_slice(source).len();
|
||||
entry.meta.data_size = u32::try_from(data_len).map_err(|_| Error::IntegerOverflow)?;
|
||||
|
||||
// Now get the slice again for writing
|
||||
let data_slice = entry.data_slice(source);
|
||||
out.extend_from_slice(data_slice);
|
||||
|
||||
let padding = (8 - (out.len() % 8)) % 8;
|
||||
if padding > 0 {
|
||||
out.resize(out.len() + padding, 0);
|
||||
}
|
||||
}
|
||||
|
||||
let mut sort_order: Vec<usize> = (0..self.entries.len()).collect();
|
||||
sort_order.sort_by(|a, b| {
|
||||
cmp_name_case_insensitive(
|
||||
entry_name_bytes(&self.entries[*a].name_raw),
|
||||
entry_name_bytes(&self.entries[*b].name_raw),
|
||||
)
|
||||
});
|
||||
|
||||
for (idx, entry) in self.entries.iter_mut().enumerate() {
|
||||
entry.meta.sort_index =
|
||||
u32::try_from(sort_order[idx]).map_err(|_| Error::IntegerOverflow)?;
|
||||
}
|
||||
|
||||
for entry in &self.entries {
|
||||
let data_offset_u32 =
|
||||
u32::try_from(entry.meta.data_offset).map_err(|_| Error::IntegerOverflow)?;
|
||||
push_u32(&mut out, entry.meta.kind);
|
||||
push_u32(&mut out, entry.meta.attr1);
|
||||
push_u32(&mut out, entry.meta.attr2);
|
||||
push_u32(&mut out, entry.meta.data_size);
|
||||
push_u32(&mut out, entry.meta.attr3);
|
||||
out.extend_from_slice(&entry.name_raw);
|
||||
push_u32(&mut out, data_offset_u32);
|
||||
push_u32(&mut out, entry.meta.sort_index);
|
||||
}
|
||||
|
||||
let total_size_u32 = u32::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
out[0..4].copy_from_slice(b"NRes");
|
||||
out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
|
||||
out[8..12].copy_from_slice(&count_u32.to_le_bytes());
|
||||
out[12..16].copy_from_slice(&total_size_u32.to_le_bytes());
|
||||
|
||||
write_atomic(&self.path, &out)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_archive(bytes: &[u8], raw_mode: bool) -> Result<(Vec<EntryRecord>, u64)> {
|
||||
if raw_mode {
|
||||
let data_size = u32::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
let entry = EntryRecord {
|
||||
meta: EntryMeta {
|
||||
kind: 0,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: String::from("RAW"),
|
||||
data_offset: 0,
|
||||
data_size,
|
||||
sort_index: 0,
|
||||
},
|
||||
name_raw: {
|
||||
let mut name = [0u8; 36];
|
||||
let bytes_name = b"RAW";
|
||||
name[..bytes_name.len()].copy_from_slice(bytes_name);
|
||||
name
|
||||
},
|
||||
};
|
||||
return Ok((
|
||||
vec![entry],
|
||||
u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
|
||||
));
|
||||
}
|
||||
|
||||
if bytes.len() < 16 {
|
||||
let mut got = [0u8; 4];
|
||||
let copy_len = bytes.len().min(4);
|
||||
got[..copy_len].copy_from_slice(&bytes[..copy_len]);
|
||||
return Err(Error::InvalidMagic { got });
|
||||
}
|
||||
|
||||
let mut magic = [0u8; 4];
|
||||
magic.copy_from_slice(&bytes[0..4]);
|
||||
if &magic != b"NRes" {
|
||||
return Err(Error::InvalidMagic { got: magic });
|
||||
}
|
||||
|
||||
let version = read_u32(bytes, 4)?;
|
||||
if version != 0x100 {
|
||||
return Err(Error::UnsupportedVersion { got: version });
|
||||
}
|
||||
|
||||
let entry_count_i32 = i32::from_le_bytes(
|
||||
bytes[8..12]
|
||||
.try_into()
|
||||
.map_err(|_| Error::IntegerOverflow)?,
|
||||
);
|
||||
if entry_count_i32 < 0 {
|
||||
return Err(Error::InvalidEntryCount {
|
||||
got: entry_count_i32,
|
||||
});
|
||||
}
|
||||
let entry_count = usize::try_from(entry_count_i32).map_err(|_| Error::IntegerOverflow)?;
|
||||
|
||||
// Validate entry_count fits in u32 (required for EntryId)
|
||||
if entry_count > u32::MAX as usize {
|
||||
return Err(Error::TooManyEntries { got: entry_count });
|
||||
}
|
||||
|
||||
let total_size = read_u32(bytes, 12)?;
|
||||
let actual_size = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
if u64::from(total_size) != actual_size {
|
||||
return Err(Error::TotalSizeMismatch {
|
||||
header: total_size,
|
||||
actual: actual_size,
|
||||
});
|
||||
}
|
||||
|
||||
let directory_len = u64::try_from(entry_count)
|
||||
.map_err(|_| Error::IntegerOverflow)?
|
||||
.checked_mul(64)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
let directory_offset =
|
||||
u64::from(total_size)
|
||||
.checked_sub(directory_len)
|
||||
.ok_or(Error::DirectoryOutOfBounds {
|
||||
directory_offset: 0,
|
||||
directory_len,
|
||||
file_len: actual_size,
|
||||
})?;
|
||||
|
||||
if directory_offset < 16 || directory_offset + directory_len > actual_size {
|
||||
return Err(Error::DirectoryOutOfBounds {
|
||||
directory_offset,
|
||||
directory_len,
|
||||
file_len: actual_size,
|
||||
});
|
||||
}
|
||||
|
||||
let mut entries = Vec::with_capacity(entry_count);
|
||||
for index in 0..entry_count {
|
||||
let base = usize::try_from(directory_offset)
|
||||
.map_err(|_| Error::IntegerOverflow)?
|
||||
.checked_add(index.checked_mul(64).ok_or(Error::IntegerOverflow)?)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
|
||||
let kind = read_u32(bytes, base)?;
|
||||
let attr1 = read_u32(bytes, base + 4)?;
|
||||
let attr2 = read_u32(bytes, base + 8)?;
|
||||
let data_size = read_u32(bytes, base + 12)?;
|
||||
let attr3 = read_u32(bytes, base + 16)?;
|
||||
|
||||
let mut name_raw = [0u8; 36];
|
||||
let name_slice = bytes
|
||||
.get(base + 20..base + 56)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
name_raw.copy_from_slice(name_slice);
|
||||
|
||||
let name_bytes = entry_name_bytes(&name_raw);
|
||||
if name_bytes.len() > 35 {
|
||||
return Err(Error::NameTooLong {
|
||||
got: name_bytes.len(),
|
||||
max: 35,
|
||||
});
|
||||
}
|
||||
|
||||
let data_offset = u64::from(read_u32(bytes, base + 56)?);
|
||||
let sort_index = read_u32(bytes, base + 60)?;
|
||||
|
||||
let end = data_offset
|
||||
.checked_add(u64::from(data_size))
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
if data_offset < 16 || end > directory_offset {
|
||||
return Err(Error::EntryDataOutOfBounds {
|
||||
id: u32::try_from(index).map_err(|_| Error::IntegerOverflow)?,
|
||||
offset: data_offset,
|
||||
size: data_size,
|
||||
directory_offset,
|
||||
});
|
||||
}
|
||||
|
||||
entries.push(EntryRecord {
|
||||
meta: EntryMeta {
|
||||
kind,
|
||||
attr1,
|
||||
attr2,
|
||||
attr3,
|
||||
name: decode_name(name_bytes),
|
||||
data_offset,
|
||||
data_size,
|
||||
sort_index,
|
||||
},
|
||||
name_raw,
|
||||
});
|
||||
}
|
||||
|
||||
Ok((entries, directory_offset))
|
||||
}
|
||||
|
||||
fn checked_range(offset: u64, size: u32, bytes_len: usize) -> Result<Range<usize>> {
|
||||
let start = usize::try_from(offset).map_err(|_| Error::IntegerOverflow)?;
|
||||
let len = usize::try_from(size).map_err(|_| Error::IntegerOverflow)?;
|
||||
let end = start.checked_add(len).ok_or(Error::IntegerOverflow)?;
|
||||
if end > bytes_len {
|
||||
return Err(Error::IntegerOverflow);
|
||||
}
|
||||
Ok(start..end)
|
||||
}
|
||||
|
||||
fn read_u32(bytes: &[u8], offset: usize) -> Result<u32> {
|
||||
let data = bytes
|
||||
.get(offset..offset + 4)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
let arr: [u8; 4] = data.try_into().map_err(|_| Error::IntegerOverflow)?;
|
||||
Ok(u32::from_le_bytes(arr))
|
||||
}
|
||||
|
||||
fn push_u32(out: &mut Vec<u8>, value: u32) {
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn encode_name_field(name: &str) -> Result<[u8; 36]> {
|
||||
let bytes = name.as_bytes();
|
||||
if bytes.contains(&0) {
|
||||
return Err(Error::NameContainsNul);
|
||||
}
|
||||
if bytes.len() > 35 {
|
||||
return Err(Error::NameTooLong {
|
||||
got: bytes.len(),
|
||||
max: 35,
|
||||
});
|
||||
}
|
||||
|
||||
let mut out = [0u8; 36];
|
||||
out[..bytes.len()].copy_from_slice(bytes);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn entry_name_bytes(raw: &[u8; 36]) -> &[u8] {
|
||||
let len = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
|
||||
&raw[..len]
|
||||
}
|
||||
|
||||
fn decode_name(name: &[u8]) -> String {
|
||||
name.iter().map(|b| char::from(*b)).collect()
|
||||
}
|
||||
|
||||
fn cmp_name_case_insensitive(a: &[u8], b: &[u8]) -> Ordering {
|
||||
let mut idx = 0usize;
|
||||
let min_len = a.len().min(b.len());
|
||||
while idx < min_len {
|
||||
let left = ascii_lower(a[idx]);
|
||||
let right = ascii_lower(b[idx]);
|
||||
if left != right {
|
||||
return left.cmp(&right);
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
a.len().cmp(&b.len())
|
||||
}
|
||||
|
||||
fn ascii_lower(value: u8) -> u8 {
|
||||
if value.is_ascii_uppercase() {
|
||||
value + 32
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
fn prefetch_pages(bytes: &[u8]) {
|
||||
use std::sync::atomic::{compiler_fence, Ordering};
|
||||
|
||||
let mut cursor = 0usize;
|
||||
let mut sink = 0u8;
|
||||
while cursor < bytes.len() {
|
||||
sink ^= bytes[cursor];
|
||||
cursor = cursor.saturating_add(4096);
|
||||
}
|
||||
compiler_fence(Ordering::SeqCst);
|
||||
let _ = sink;
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, content: &[u8]) -> Result<()> {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("archive");
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
let mut temp_path = None;
|
||||
for attempt in 0..128u32 {
|
||||
let name = format!(
|
||||
".{}.tmp.{}.{}.{}",
|
||||
file_name,
|
||||
std::process::id(),
|
||||
unix_time_nanos(),
|
||||
attempt
|
||||
);
|
||||
let candidate = parent.join(name);
|
||||
let opened = FsOpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&candidate);
|
||||
if let Ok(mut file) = opened {
|
||||
file.write_all(content)?;
|
||||
file.sync_all()?;
|
||||
temp_path = Some((candidate, file));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let Some((tmp_path, mut file)) = temp_path else {
|
||||
return Err(Error::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"failed to create temporary file for atomic write",
|
||||
)));
|
||||
};
|
||||
|
||||
file.flush()?;
|
||||
drop(file);
|
||||
|
||||
if let Err(err) = replace_file_atomically(&tmp_path, path) {
|
||||
let _ = fs::remove_file(&tmp_path);
|
||||
return Err(Error::Io(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
fs::rename(src, dst)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
use std::iter;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
|
||||
};
|
||||
|
||||
let src_wide: Vec<u16> = src.as_os_str().encode_wide().chain(iter::once(0)).collect();
|
||||
let dst_wide: Vec<u16> = dst.as_os_str().encode_wide().chain(iter::once(0)).collect();
|
||||
|
||||
// Replace destination in one OS call, avoiding remove+rename gaps on Windows.
|
||||
let ok = unsafe {
|
||||
MoveFileExW(
|
||||
src_wide.as_ptr(),
|
||||
dst_wide.as_ptr(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
|
||||
)
|
||||
};
|
||||
|
||||
if ok == 0 {
|
||||
Err(std::io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_time_nanos() -> u128 {
|
||||
match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||
Ok(duration) => duration.as_nanos(),
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
996
crates/nres/src/tests.rs
Normal file
996
crates/nres/src/tests.rs
Normal file
@@ -0,0 +1,996 @@
|
||||
use super::*;
|
||||
use std::any::Any;
|
||||
use std::fs;
|
||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SyntheticEntry<'a> {
|
||||
kind: u32,
|
||||
attr1: u32,
|
||||
attr2: u32,
|
||||
attr3: u32,
|
||||
name: &'a str,
|
||||
data: &'a [u8],
|
||||
}
|
||||
|
||||
fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_files_recursive(&path, out);
|
||||
} else if path.is_file() {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn nres_test_files() -> Vec<PathBuf> {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("testdata")
|
||||
.join("nres");
|
||||
let mut files = Vec::new();
|
||||
collect_files_recursive(&root, &mut files);
|
||||
files.sort();
|
||||
files
|
||||
.into_iter()
|
||||
.filter(|path| {
|
||||
fs::read(path)
|
||||
.map(|data| data.get(0..4) == Some(b"NRes"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn make_temp_copy(original: &Path, bytes: &[u8]) -> PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
let file_name = original
|
||||
.file_name()
|
||||
.and_then(|v| v.to_str())
|
||||
.unwrap_or("archive");
|
||||
path.push(format!(
|
||||
"nres-test-{}-{}-{}",
|
||||
std::process::id(),
|
||||
unix_time_nanos(),
|
||||
file_name
|
||||
));
|
||||
fs::write(&path, bytes).expect("failed to create temp file");
|
||||
path
|
||||
}
|
||||
|
||||
fn panic_message(payload: Box<dyn Any + Send>) -> String {
|
||||
let any = payload.as_ref();
|
||||
if let Some(message) = any.downcast_ref::<String>() {
|
||||
return message.clone();
|
||||
}
|
||||
if let Some(message) = any.downcast_ref::<&str>() {
|
||||
return (*message).to_string();
|
||||
}
|
||||
String::from("panic without message")
|
||||
}
|
||||
|
||||
fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
|
||||
let slice = bytes
|
||||
.get(offset..offset + 4)
|
||||
.expect("u32 read out of bounds in test");
|
||||
let arr: [u8; 4] = slice.try_into().expect("u32 conversion failed in test");
|
||||
u32::from_le_bytes(arr)
|
||||
}
|
||||
|
||||
fn read_i32_le(bytes: &[u8], offset: usize) -> i32 {
|
||||
let slice = bytes
|
||||
.get(offset..offset + 4)
|
||||
.expect("i32 read out of bounds in test");
|
||||
let arr: [u8; 4] = slice.try_into().expect("i32 conversion failed in test");
|
||||
i32::from_le_bytes(arr)
|
||||
}
|
||||
|
||||
fn name_field_bytes(raw: &[u8; 36]) -> Option<&[u8]> {
|
||||
let nul = raw.iter().position(|value| *value == 0)?;
|
||||
Some(&raw[..nul])
|
||||
}
|
||||
|
||||
fn build_nres_bytes(entries: &[SyntheticEntry<'_>]) -> Vec<u8> {
|
||||
let mut out = vec![0u8; 16];
|
||||
let mut offsets = Vec::with_capacity(entries.len());
|
||||
|
||||
for entry in entries {
|
||||
offsets.push(u32::try_from(out.len()).expect("offset overflow"));
|
||||
out.extend_from_slice(entry.data);
|
||||
let padding = (8 - (out.len() % 8)) % 8;
|
||||
if padding > 0 {
|
||||
out.resize(out.len() + padding, 0);
|
||||
}
|
||||
}
|
||||
|
||||
let mut sort_order: Vec<usize> = (0..entries.len()).collect();
|
||||
sort_order.sort_by(|a, b| {
|
||||
cmp_name_case_insensitive(entries[*a].name.as_bytes(), entries[*b].name.as_bytes())
|
||||
});
|
||||
|
||||
for (index, entry) in entries.iter().enumerate() {
|
||||
let mut name_raw = [0u8; 36];
|
||||
let name_bytes = entry.name.as_bytes();
|
||||
assert!(name_bytes.len() <= 35, "name too long in fixture");
|
||||
name_raw[..name_bytes.len()].copy_from_slice(name_bytes);
|
||||
|
||||
push_u32(&mut out, entry.kind);
|
||||
push_u32(&mut out, entry.attr1);
|
||||
push_u32(&mut out, entry.attr2);
|
||||
push_u32(
|
||||
&mut out,
|
||||
u32::try_from(entry.data.len()).expect("data size overflow"),
|
||||
);
|
||||
push_u32(&mut out, entry.attr3);
|
||||
out.extend_from_slice(&name_raw);
|
||||
push_u32(&mut out, offsets[index]);
|
||||
push_u32(
|
||||
&mut out,
|
||||
u32::try_from(sort_order[index]).expect("sort index overflow"),
|
||||
);
|
||||
}
|
||||
|
||||
out[0..4].copy_from_slice(b"NRes");
|
||||
out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
|
||||
out[8..12].copy_from_slice(
|
||||
&u32::try_from(entries.len())
|
||||
.expect("count overflow")
|
||||
.to_le_bytes(),
|
||||
);
|
||||
let total_size = u32::try_from(out.len()).expect("size overflow");
|
||||
out[12..16].copy_from_slice(&total_size.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_docs_structural_invariants_all_files() {
|
||||
let files = nres_test_files();
|
||||
if files.is_empty() {
|
||||
eprintln!(
|
||||
"skipping nres_docs_structural_invariants_all_files: no NRes archives in testdata/nres"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for path in files {
|
||||
let bytes = fs::read(&path).unwrap_or_else(|err| {
|
||||
panic!("failed to read {}: {err}", path.display());
|
||||
});
|
||||
|
||||
assert!(
|
||||
bytes.len() >= 16,
|
||||
"NRes header too short in {}",
|
||||
path.display()
|
||||
);
|
||||
assert_eq!(&bytes[0..4], b"NRes", "bad magic in {}", path.display());
|
||||
assert_eq!(
|
||||
read_u32_le(&bytes, 4),
|
||||
0x100,
|
||||
"bad version in {}",
|
||||
path.display()
|
||||
);
|
||||
assert_eq!(
|
||||
usize::try_from(read_u32_le(&bytes, 12)).expect("size overflow"),
|
||||
bytes.len(),
|
||||
"header.total_size mismatch in {}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
let entry_count_i32 = read_i32_le(&bytes, 8);
|
||||
assert!(
|
||||
entry_count_i32 >= 0,
|
||||
"negative entry_count={} in {}",
|
||||
entry_count_i32,
|
||||
path.display()
|
||||
);
|
||||
let entry_count = usize::try_from(entry_count_i32).expect("entry_count overflow");
|
||||
let directory_len = entry_count.checked_mul(64).expect("directory_len overflow");
|
||||
let directory_offset = bytes
|
||||
.len()
|
||||
.checked_sub(directory_len)
|
||||
.unwrap_or_else(|| panic!("directory underflow in {}", path.display()));
|
||||
assert!(
|
||||
directory_offset >= 16,
|
||||
"directory offset before data area in {}",
|
||||
path.display()
|
||||
);
|
||||
assert_eq!(
|
||||
directory_offset + directory_len,
|
||||
bytes.len(),
|
||||
"directory not at file end in {}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
let mut sort_indices = Vec::with_capacity(entry_count);
|
||||
let mut entries = Vec::with_capacity(entry_count);
|
||||
for index in 0..entry_count {
|
||||
let base = directory_offset + index * 64;
|
||||
let size = usize::try_from(read_u32_le(&bytes, base + 12)).expect("size overflow");
|
||||
let data_offset =
|
||||
usize::try_from(read_u32_le(&bytes, base + 56)).expect("offset overflow");
|
||||
let sort_index =
|
||||
usize::try_from(read_u32_le(&bytes, base + 60)).expect("sort_index overflow");
|
||||
|
||||
let mut name_raw = [0u8; 36];
|
||||
name_raw.copy_from_slice(
|
||||
bytes
|
||||
.get(base + 20..base + 56)
|
||||
.expect("name field out of bounds in test"),
|
||||
);
|
||||
let name_bytes = name_field_bytes(&name_raw).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"name field without NUL terminator in {} entry #{index}",
|
||||
path.display()
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
name_bytes.len() <= 35,
|
||||
"name longer than 35 bytes in {} entry #{index}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
sort_indices.push(sort_index);
|
||||
entries.push((name_bytes.to_vec(), data_offset, size));
|
||||
}
|
||||
|
||||
let mut expected_sort: Vec<usize> = (0..entry_count).collect();
|
||||
expected_sort.sort_by(|a, b| cmp_name_case_insensitive(&entries[*a].0, &entries[*b].0));
|
||||
assert_eq!(
|
||||
sort_indices,
|
||||
expected_sort,
|
||||
"sort_index table mismatch in {}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
let mut data_regions: Vec<(usize, usize)> =
|
||||
entries.iter().map(|(_, off, size)| (*off, *size)).collect();
|
||||
data_regions.sort_by_key(|(off, _)| *off);
|
||||
|
||||
for (idx, (data_offset, size)) in data_regions.iter().enumerate() {
|
||||
assert_eq!(
|
||||
data_offset % 8,
|
||||
0,
|
||||
"data offset is not 8-byte aligned in {} (region #{idx})",
|
||||
path.display()
|
||||
);
|
||||
assert!(
|
||||
*data_offset >= 16,
|
||||
"data offset before header end in {} (region #{idx})",
|
||||
path.display()
|
||||
);
|
||||
assert!(
|
||||
data_offset.checked_add(*size).unwrap_or(usize::MAX) <= directory_offset,
|
||||
"data region overlaps directory in {} (region #{idx})",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
for pair in data_regions.windows(2) {
|
||||
let (start, size) = pair[0];
|
||||
let (next_start, _) = pair[1];
|
||||
let end = start
|
||||
.checked_add(size)
|
||||
.unwrap_or_else(|| panic!("size overflow in {}", path.display()));
|
||||
assert!(
|
||||
end <= next_start,
|
||||
"overlapping data regions in {}: [{start}, {end}) and next at {next_start}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
for (offset, value) in bytes[end..next_start].iter().enumerate() {
|
||||
assert_eq!(
|
||||
*value,
|
||||
0,
|
||||
"non-zero alignment padding in {} at offset {}",
|
||||
path.display(),
|
||||
end + offset
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_read_and_roundtrip_all_files() {
|
||||
let files = nres_test_files();
|
||||
if files.is_empty() {
|
||||
eprintln!("skipping nres_read_and_roundtrip_all_files: no NRes archives in testdata/nres");
|
||||
return;
|
||||
}
|
||||
|
||||
let checked = files.len();
|
||||
let mut success = 0usize;
|
||||
let mut failures = Vec::new();
|
||||
|
||||
for path in files {
|
||||
let display_path = path.display().to_string();
|
||||
let result = catch_unwind(AssertUnwindSafe(|| {
|
||||
let original = fs::read(&path).expect("failed to read archive");
|
||||
let archive = Archive::open_path(&path)
|
||||
.unwrap_or_else(|err| panic!("failed to open {}: {err}", path.display()));
|
||||
|
||||
let count = archive.entry_count();
|
||||
assert_eq!(
|
||||
count,
|
||||
archive.entries().count(),
|
||||
"entry count mismatch: {}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
for idx in 0..count {
|
||||
let id = EntryId(idx as u32);
|
||||
let entry = archive
|
||||
.get(id)
|
||||
.unwrap_or_else(|| panic!("missing entry #{idx} in {}", path.display()));
|
||||
|
||||
let payload = archive.read(id).unwrap_or_else(|err| {
|
||||
panic!("read failed for {} entry #{idx}: {err}", path.display())
|
||||
});
|
||||
|
||||
let mut out = Vec::new();
|
||||
let written = archive.read_into(id, &mut out).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"read_into failed for {} entry #{idx}: {err}",
|
||||
path.display()
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
written,
|
||||
payload.as_slice().len(),
|
||||
"size mismatch in {} entry #{idx}",
|
||||
path.display()
|
||||
);
|
||||
assert_eq!(
|
||||
out.as_slice(),
|
||||
payload.as_slice(),
|
||||
"payload mismatch in {} entry #{idx}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
let raw = archive
|
||||
.raw_slice(id)
|
||||
.unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"raw_slice failed for {} entry #{idx}: {err}",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
.expect("raw_slice must return Some for file-backed archive");
|
||||
assert_eq!(
|
||||
raw,
|
||||
payload.as_slice(),
|
||||
"raw slice mismatch in {} entry #{idx}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
let found = archive.find(&entry.meta.name).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"find failed for name '{}' in {}",
|
||||
entry.meta.name,
|
||||
path.display()
|
||||
)
|
||||
});
|
||||
let found_meta = archive.get(found).expect("find returned invalid id");
|
||||
assert!(
|
||||
found_meta.meta.name.eq_ignore_ascii_case(&entry.meta.name),
|
||||
"find returned unrelated entry in {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let temp_copy = make_temp_copy(&path, &original);
|
||||
let mut editor = Archive::edit_path(&temp_copy)
|
||||
.unwrap_or_else(|err| panic!("edit_path failed for {}: {err}", path.display()));
|
||||
|
||||
for idx in 0..count {
|
||||
let data = archive
|
||||
.read(EntryId(idx as u32))
|
||||
.unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"read before replace failed for {} entry #{idx}: {err}",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
.into_owned();
|
||||
editor
|
||||
.replace_data(EntryId(idx as u32), &data)
|
||||
.unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"replace_data failed for {} entry #{idx}: {err}",
|
||||
path.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
editor
|
||||
.commit()
|
||||
.unwrap_or_else(|err| panic!("commit failed for {}: {err}", path.display()));
|
||||
let rebuilt = fs::read(&temp_copy).expect("failed to read rebuilt archive");
|
||||
let _ = fs::remove_file(&temp_copy);
|
||||
|
||||
assert_eq!(
|
||||
original,
|
||||
rebuilt,
|
||||
"byte-to-byte roundtrip mismatch for {}",
|
||||
path.display()
|
||||
);
|
||||
}));
|
||||
|
||||
match result {
|
||||
Ok(()) => success += 1,
|
||||
Err(payload) => {
|
||||
failures.push(format!("{}: {}", display_path, panic_message(payload)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let failed = failures.len();
|
||||
eprintln!(
|
||||
"NRes summary: checked={}, success={}, failed={}",
|
||||
checked, success, failed
|
||||
);
|
||||
if !failures.is_empty() {
|
||||
panic!(
|
||||
"NRes validation failed.\nsummary: checked={}, success={}, failed={}\n{}",
|
||||
checked,
|
||||
success,
|
||||
failed,
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_raw_mode_exposes_whole_file() {
|
||||
let files = nres_test_files();
|
||||
let Some(first) = files.first() else {
|
||||
eprintln!("skipping nres_raw_mode_exposes_whole_file: no NRes archives in testdata/nres");
|
||||
return;
|
||||
};
|
||||
let original = fs::read(first).expect("failed to read archive");
|
||||
let arc: Arc<[u8]> = Arc::from(original.clone().into_boxed_slice());
|
||||
|
||||
let archive = Archive::open_bytes(
|
||||
arc,
|
||||
OpenOptions {
|
||||
raw_mode: true,
|
||||
sequential_hint: false,
|
||||
prefetch_pages: false,
|
||||
},
|
||||
)
|
||||
.expect("raw mode open failed");
|
||||
|
||||
assert_eq!(archive.entry_count(), 1);
|
||||
let data = archive.read(EntryId(0)).expect("raw read failed");
|
||||
assert_eq!(data.as_slice(), original.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_raw_mode_accepts_non_nres_bytes() {
|
||||
let payload = b"not-an-nres-archive".to_vec();
|
||||
let bytes: Arc<[u8]> = Arc::from(payload.clone().into_boxed_slice());
|
||||
|
||||
match Archive::open_bytes(bytes.clone(), OpenOptions::default()) {
|
||||
Err(Error::InvalidMagic { .. }) => {}
|
||||
other => panic!("expected InvalidMagic without raw_mode, got {other:?}"),
|
||||
}
|
||||
|
||||
let archive = Archive::open_bytes(
|
||||
bytes,
|
||||
OpenOptions {
|
||||
raw_mode: true,
|
||||
sequential_hint: false,
|
||||
prefetch_pages: false,
|
||||
},
|
||||
)
|
||||
.expect("raw_mode should accept any bytes");
|
||||
|
||||
assert_eq!(archive.entry_count(), 1);
|
||||
assert_eq!(archive.find("raw"), Some(EntryId(0)));
|
||||
assert_eq!(
|
||||
archive
|
||||
.read(EntryId(0))
|
||||
.expect("raw read failed")
|
||||
.as_slice(),
|
||||
payload.as_slice()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_open_options_hints_do_not_change_payload() {
|
||||
let payload: Vec<u8> = (0..70_000u32).map(|v| (v % 251) as u8).collect();
|
||||
let src = build_nres_bytes(&[SyntheticEntry {
|
||||
kind: 7,
|
||||
attr1: 70,
|
||||
attr2: 700,
|
||||
attr3: 7000,
|
||||
name: "big.bin",
|
||||
data: &payload,
|
||||
}]);
|
||||
let arc: Arc<[u8]> = Arc::from(src.into_boxed_slice());
|
||||
|
||||
let baseline = Archive::open_bytes(arc.clone(), OpenOptions::default())
|
||||
.expect("baseline open should succeed");
|
||||
let hinted = Archive::open_bytes(
|
||||
arc,
|
||||
OpenOptions {
|
||||
raw_mode: false,
|
||||
sequential_hint: true,
|
||||
prefetch_pages: true,
|
||||
},
|
||||
)
|
||||
.expect("open with hints should succeed");
|
||||
|
||||
assert_eq!(baseline.entry_count(), 1);
|
||||
assert_eq!(hinted.entry_count(), 1);
|
||||
assert_eq!(baseline.find("BIG.BIN"), Some(EntryId(0)));
|
||||
assert_eq!(hinted.find("big.bin"), Some(EntryId(0)));
|
||||
assert_eq!(
|
||||
baseline
|
||||
.read(EntryId(0))
|
||||
.expect("baseline read failed")
|
||||
.as_slice(),
|
||||
hinted
|
||||
.read(EntryId(0))
|
||||
.expect("hinted read failed")
|
||||
.as_slice()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_commit_empty_archive_has_minimal_layout() {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"nres-empty-commit-{}-{}.lib",
|
||||
std::process::id(),
|
||||
unix_time_nanos()
|
||||
));
|
||||
fs::write(&path, build_nres_bytes(&[])).expect("write empty archive failed");
|
||||
|
||||
Archive::edit_path(&path)
|
||||
.expect("edit_path failed for empty archive")
|
||||
.commit()
|
||||
.expect("commit failed for empty archive");
|
||||
|
||||
let bytes = fs::read(&path).expect("failed to read committed archive");
|
||||
assert_eq!(bytes.len(), 16, "empty archive must contain only header");
|
||||
assert_eq!(&bytes[0..4], b"NRes");
|
||||
assert_eq!(read_u32_le(&bytes, 4), 0x100);
|
||||
assert_eq!(read_u32_le(&bytes, 8), 0);
|
||||
assert_eq!(read_u32_le(&bytes, 12), 16);
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_commit_recomputes_header_directory_and_sort_table() {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"nres-commit-layout-{}-{}.lib",
|
||||
std::process::id(),
|
||||
unix_time_nanos()
|
||||
));
|
||||
fs::write(&path, build_nres_bytes(&[])).expect("write empty archive failed");
|
||||
|
||||
let mut editor = Archive::edit_path(&path).expect("edit_path failed");
|
||||
editor
|
||||
.add(NewEntry {
|
||||
kind: 10,
|
||||
attr1: 1,
|
||||
attr2: 2,
|
||||
attr3: 3,
|
||||
name: "Zulu",
|
||||
data: b"aaaaa",
|
||||
})
|
||||
.expect("add #0 failed");
|
||||
editor
|
||||
.add(NewEntry {
|
||||
kind: 11,
|
||||
attr1: 4,
|
||||
attr2: 5,
|
||||
attr3: 6,
|
||||
name: "alpha",
|
||||
data: b"bbbbbbbb",
|
||||
})
|
||||
.expect("add #1 failed");
|
||||
editor
|
||||
.add(NewEntry {
|
||||
kind: 12,
|
||||
attr1: 7,
|
||||
attr2: 8,
|
||||
attr3: 9,
|
||||
name: "Beta",
|
||||
data: b"cccc",
|
||||
})
|
||||
.expect("add #2 failed");
|
||||
editor.commit().expect("commit failed");
|
||||
|
||||
let bytes = fs::read(&path).expect("failed to read committed archive");
|
||||
assert_eq!(&bytes[0..4], b"NRes");
|
||||
assert_eq!(read_u32_le(&bytes, 4), 0x100);
|
||||
|
||||
let entry_count = usize::try_from(read_u32_le(&bytes, 8)).expect("entry_count overflow");
|
||||
let total_size = usize::try_from(read_u32_le(&bytes, 12)).expect("total_size overflow");
|
||||
assert_eq!(entry_count, 3);
|
||||
assert_eq!(total_size, bytes.len());
|
||||
|
||||
let directory_offset = total_size
|
||||
.checked_sub(entry_count * 64)
|
||||
.expect("invalid directory offset");
|
||||
assert!(directory_offset >= 16);
|
||||
|
||||
let mut sort_indices = Vec::new();
|
||||
let mut prev_data_end = 16usize;
|
||||
for idx in 0..entry_count {
|
||||
let base = directory_offset + idx * 64;
|
||||
let data_size = usize::try_from(read_u32_le(&bytes, base + 12)).expect("size overflow");
|
||||
let data_offset = usize::try_from(read_u32_le(&bytes, base + 56)).expect("offset overflow");
|
||||
let sort_index =
|
||||
usize::try_from(read_u32_le(&bytes, base + 60)).expect("sort index overflow");
|
||||
|
||||
assert_eq!(
|
||||
data_offset % 8,
|
||||
0,
|
||||
"entry #{idx} data offset must be 8-byte aligned"
|
||||
);
|
||||
assert!(
|
||||
data_offset >= prev_data_end,
|
||||
"entry #{idx} offset regressed"
|
||||
);
|
||||
assert!(
|
||||
data_offset + data_size <= directory_offset,
|
||||
"entry #{idx} overlaps directory"
|
||||
);
|
||||
prev_data_end = data_offset + data_size;
|
||||
sort_indices.push(sort_index);
|
||||
}
|
||||
|
||||
let names = ["Zulu", "alpha", "Beta"];
|
||||
let mut expected_sort: Vec<usize> = (0..names.len()).collect();
|
||||
expected_sort
|
||||
.sort_by(|a, b| cmp_name_case_insensitive(names[*a].as_bytes(), names[*b].as_bytes()));
|
||||
assert_eq!(
|
||||
sort_indices, expected_sort,
|
||||
"sort table must contain original indexes in case-insensitive alphabetical order"
|
||||
);
|
||||
|
||||
let archive = Archive::open_path(&path).expect("re-open failed");
|
||||
assert_eq!(archive.find("zulu"), Some(EntryId(0)));
|
||||
assert_eq!(archive.find("ALPHA"), Some(EntryId(1)));
|
||||
assert_eq!(archive.find("beta"), Some(EntryId(2)));
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_synthetic_read_find_and_edit() {
|
||||
let payload_a = b"alpha";
|
||||
let payload_b = b"B";
|
||||
let payload_c = b"";
|
||||
let src = build_nres_bytes(&[
|
||||
SyntheticEntry {
|
||||
kind: 1,
|
||||
attr1: 10,
|
||||
attr2: 20,
|
||||
attr3: 30,
|
||||
name: "Alpha.TXT",
|
||||
data: payload_a,
|
||||
},
|
||||
SyntheticEntry {
|
||||
kind: 2,
|
||||
attr1: 11,
|
||||
attr2: 21,
|
||||
attr3: 31,
|
||||
name: "beta.bin",
|
||||
data: payload_b,
|
||||
},
|
||||
SyntheticEntry {
|
||||
kind: 3,
|
||||
attr1: 12,
|
||||
attr2: 22,
|
||||
attr3: 32,
|
||||
name: "Gamma",
|
||||
data: payload_c,
|
||||
},
|
||||
]);
|
||||
|
||||
let archive = Archive::open_bytes(
|
||||
Arc::from(src.clone().into_boxed_slice()),
|
||||
OpenOptions::default(),
|
||||
)
|
||||
.expect("open synthetic nres failed");
|
||||
|
||||
assert_eq!(archive.entry_count(), 3);
|
||||
assert_eq!(archive.find("alpha.txt"), Some(EntryId(0)));
|
||||
assert_eq!(archive.find("BETA.BIN"), Some(EntryId(1)));
|
||||
assert_eq!(archive.find("gAmMa"), Some(EntryId(2)));
|
||||
assert_eq!(archive.find("missing"), None);
|
||||
|
||||
assert_eq!(
|
||||
archive.read(EntryId(0)).expect("read #0 failed").as_slice(),
|
||||
payload_a
|
||||
);
|
||||
assert_eq!(
|
||||
archive.read(EntryId(1)).expect("read #1 failed").as_slice(),
|
||||
payload_b
|
||||
);
|
||||
assert_eq!(
|
||||
archive.read(EntryId(2)).expect("read #2 failed").as_slice(),
|
||||
payload_c
|
||||
);
|
||||
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"nres-synth-edit-{}-{}.lib",
|
||||
std::process::id(),
|
||||
unix_time_nanos()
|
||||
));
|
||||
fs::write(&path, &src).expect("write temp synthetic archive failed");
|
||||
|
||||
let mut editor = Archive::edit_path(&path).expect("edit_path on synthetic archive failed");
|
||||
editor
|
||||
.replace_data(EntryId(1), b"replaced")
|
||||
.expect("replace_data failed");
|
||||
let added = editor
|
||||
.add(NewEntry {
|
||||
kind: 4,
|
||||
attr1: 13,
|
||||
attr2: 23,
|
||||
attr3: 33,
|
||||
name: "delta",
|
||||
data: b"new payload",
|
||||
})
|
||||
.expect("add failed");
|
||||
assert_eq!(added, EntryId(3));
|
||||
editor.remove(EntryId(2)).expect("remove failed");
|
||||
editor.commit().expect("commit failed");
|
||||
|
||||
let edited = Archive::open_path(&path).expect("re-open edited archive failed");
|
||||
assert_eq!(edited.entry_count(), 3);
|
||||
assert_eq!(
|
||||
edited
|
||||
.read(edited.find("beta.bin").expect("find beta.bin failed"))
|
||||
.expect("read beta.bin failed")
|
||||
.as_slice(),
|
||||
b"replaced"
|
||||
);
|
||||
assert_eq!(
|
||||
edited
|
||||
.read(edited.find("delta").expect("find delta failed"))
|
||||
.expect("read delta failed")
|
||||
.as_slice(),
|
||||
b"new payload"
|
||||
);
|
||||
assert_eq!(edited.find("gamma"), None);
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_max_name_length_roundtrip() {
|
||||
let max_name = "12345678901234567890123456789012345";
|
||||
assert_eq!(max_name.len(), 35);
|
||||
|
||||
let src = build_nres_bytes(&[SyntheticEntry {
|
||||
kind: 9,
|
||||
attr1: 1,
|
||||
attr2: 2,
|
||||
attr3: 3,
|
||||
name: max_name,
|
||||
data: b"payload",
|
||||
}]);
|
||||
|
||||
let archive = Archive::open_bytes(Arc::from(src.into_boxed_slice()), OpenOptions::default())
|
||||
.expect("open synthetic nres failed");
|
||||
|
||||
assert_eq!(archive.entry_count(), 1);
|
||||
assert_eq!(archive.find(max_name), Some(EntryId(0)));
|
||||
assert_eq!(
|
||||
archive.find(&max_name.to_ascii_lowercase()),
|
||||
Some(EntryId(0))
|
||||
);
|
||||
|
||||
let entry = archive.get(EntryId(0)).expect("missing entry 0");
|
||||
assert_eq!(entry.meta.name, max_name);
|
||||
assert_eq!(
|
||||
archive
|
||||
.read(EntryId(0))
|
||||
.expect("read payload failed")
|
||||
.as_slice(),
|
||||
b"payload"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_find_falls_back_when_sort_index_is_out_of_range() {
|
||||
let mut bytes = build_nres_bytes(&[
|
||||
SyntheticEntry {
|
||||
kind: 1,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: "Alpha",
|
||||
data: b"a",
|
||||
},
|
||||
SyntheticEntry {
|
||||
kind: 2,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: "Beta",
|
||||
data: b"b",
|
||||
},
|
||||
SyntheticEntry {
|
||||
kind: 3,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: "Gamma",
|
||||
data: b"c",
|
||||
},
|
||||
]);
|
||||
|
||||
let entry_count = 3usize;
|
||||
let directory_offset = bytes
|
||||
.len()
|
||||
.checked_sub(entry_count * 64)
|
||||
.expect("directory offset underflow");
|
||||
let mid_entry_sort_index = directory_offset + 64 + 60;
|
||||
bytes[mid_entry_sort_index..mid_entry_sort_index + 4].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
|
||||
let archive = Archive::open_bytes(Arc::from(bytes.into_boxed_slice()), OpenOptions::default())
|
||||
.expect("open archive with corrupted sort index failed");
|
||||
|
||||
assert_eq!(archive.find("alpha"), Some(EntryId(0)));
|
||||
assert_eq!(archive.find("BETA"), Some(EntryId(1)));
|
||||
assert_eq!(archive.find("gamma"), Some(EntryId(2)));
|
||||
assert_eq!(archive.find("missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_validation_error_cases() {
|
||||
let valid = build_nres_bytes(&[SyntheticEntry {
|
||||
kind: 1,
|
||||
attr1: 2,
|
||||
attr2: 3,
|
||||
attr3: 4,
|
||||
name: "ok",
|
||||
data: b"1234",
|
||||
}]);
|
||||
|
||||
let mut invalid_magic = valid.clone();
|
||||
invalid_magic[0..4].copy_from_slice(b"FAIL");
|
||||
match Archive::open_bytes(
|
||||
Arc::from(invalid_magic.into_boxed_slice()),
|
||||
OpenOptions::default(),
|
||||
) {
|
||||
Err(Error::InvalidMagic { .. }) => {}
|
||||
other => panic!("expected InvalidMagic, got {other:?}"),
|
||||
}
|
||||
|
||||
let mut invalid_version = valid.clone();
|
||||
invalid_version[4..8].copy_from_slice(&0x200_u32.to_le_bytes());
|
||||
match Archive::open_bytes(
|
||||
Arc::from(invalid_version.into_boxed_slice()),
|
||||
OpenOptions::default(),
|
||||
) {
|
||||
Err(Error::UnsupportedVersion { got }) => assert_eq!(got, 0x200),
|
||||
other => panic!("expected UnsupportedVersion, got {other:?}"),
|
||||
}
|
||||
|
||||
let mut bad_total = valid.clone();
|
||||
bad_total[12..16].copy_from_slice(&0_u32.to_le_bytes());
|
||||
match Archive::open_bytes(
|
||||
Arc::from(bad_total.into_boxed_slice()),
|
||||
OpenOptions::default(),
|
||||
) {
|
||||
Err(Error::TotalSizeMismatch { .. }) => {}
|
||||
other => panic!("expected TotalSizeMismatch, got {other:?}"),
|
||||
}
|
||||
|
||||
let mut bad_count = valid.clone();
|
||||
bad_count[8..12].copy_from_slice(&(-1_i32).to_le_bytes());
|
||||
match Archive::open_bytes(
|
||||
Arc::from(bad_count.into_boxed_slice()),
|
||||
OpenOptions::default(),
|
||||
) {
|
||||
Err(Error::InvalidEntryCount { got }) => assert_eq!(got, -1),
|
||||
other => panic!("expected InvalidEntryCount, got {other:?}"),
|
||||
}
|
||||
|
||||
let mut bad_dir = valid.clone();
|
||||
bad_dir[8..12].copy_from_slice(&1000_u32.to_le_bytes());
|
||||
match Archive::open_bytes(
|
||||
Arc::from(bad_dir.into_boxed_slice()),
|
||||
OpenOptions::default(),
|
||||
) {
|
||||
Err(Error::DirectoryOutOfBounds { .. }) => {}
|
||||
other => panic!("expected DirectoryOutOfBounds, got {other:?}"),
|
||||
}
|
||||
|
||||
let mut long_name = valid.clone();
|
||||
let entry_base = long_name.len() - 64;
|
||||
for b in &mut long_name[entry_base + 20..entry_base + 56] {
|
||||
*b = b'X';
|
||||
}
|
||||
match Archive::open_bytes(
|
||||
Arc::from(long_name.into_boxed_slice()),
|
||||
OpenOptions::default(),
|
||||
) {
|
||||
Err(Error::NameTooLong { .. }) => {}
|
||||
other => panic!("expected NameTooLong, got {other:?}"),
|
||||
}
|
||||
|
||||
let mut bad_data = valid.clone();
|
||||
bad_data[entry_base + 56..entry_base + 60].copy_from_slice(&12_u32.to_le_bytes());
|
||||
bad_data[entry_base + 12..entry_base + 16].copy_from_slice(&32_u32.to_le_bytes());
|
||||
match Archive::open_bytes(
|
||||
Arc::from(bad_data.into_boxed_slice()),
|
||||
OpenOptions::default(),
|
||||
) {
|
||||
Err(Error::EntryDataOutOfBounds { .. }) => {}
|
||||
other => panic!("expected EntryDataOutOfBounds, got {other:?}"),
|
||||
}
|
||||
|
||||
let archive = Archive::open_bytes(Arc::from(valid.into_boxed_slice()), OpenOptions::default())
|
||||
.expect("open valid archive failed");
|
||||
match archive.read(EntryId(99)) {
|
||||
Err(Error::EntryIdOutOfRange { .. }) => {}
|
||||
other => panic!("expected EntryIdOutOfRange, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_editor_validation_error_cases() {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"nres-editor-errors-{}-{}.lib",
|
||||
std::process::id(),
|
||||
unix_time_nanos()
|
||||
));
|
||||
let src = build_nres_bytes(&[]);
|
||||
fs::write(&path, src).expect("write empty archive failed");
|
||||
|
||||
let mut editor = Archive::edit_path(&path).expect("edit_path failed");
|
||||
|
||||
let long_name = "X".repeat(36);
|
||||
match editor.add(NewEntry {
|
||||
kind: 0,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: &long_name,
|
||||
data: b"",
|
||||
}) {
|
||||
Err(Error::NameTooLong { .. }) => {}
|
||||
other => panic!("expected NameTooLong, got {other:?}"),
|
||||
}
|
||||
|
||||
match editor.add(NewEntry {
|
||||
kind: 0,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: "bad\0name",
|
||||
data: b"",
|
||||
}) {
|
||||
Err(Error::NameContainsNul) => {}
|
||||
other => panic!("expected NameContainsNul, got {other:?}"),
|
||||
}
|
||||
|
||||
match editor.replace_data(EntryId(0), b"x") {
|
||||
Err(Error::EntryIdOutOfRange { .. }) => {}
|
||||
other => panic!("expected EntryIdOutOfRange, got {other:?}"),
|
||||
}
|
||||
|
||||
match editor.remove(EntryId(0)) {
|
||||
Err(Error::EntryIdOutOfRange { .. }) => {}
|
||||
other => panic!("expected EntryIdOutOfRange, got {other:?}"),
|
||||
}
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
8
crates/rsli/Cargo.toml
Normal file
8
crates/rsli/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "rsli"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
58
crates/rsli/README.md
Normal file
58
crates/rsli/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# rsli
|
||||
|
||||
Rust-библиотека для чтения архивов формата **RsLi**.
|
||||
|
||||
## Что умеет
|
||||
|
||||
- Открытие библиотеки из файла (`open_path`, `open_path_with`).
|
||||
- Дешифрование таблицы записей (XOR stream cipher).
|
||||
- Поддержка AO-трейлера и media overlay (`allow_ao_trailer`).
|
||||
- Поддержка quirk для Deflate `EOF+1` (`allow_deflate_eof_plus_one`).
|
||||
- Поиск по имени (`find`, c приведением запроса к uppercase).
|
||||
- Загрузка данных:
|
||||
- `load`, `load_into`, `load_packed`, `unpack`, `load_fast`.
|
||||
|
||||
## Поддерживаемые методы упаковки
|
||||
|
||||
- `0x000` None
|
||||
- `0x020` XorOnly
|
||||
- `0x040` Lzss
|
||||
- `0x060` XorLzss
|
||||
- `0x080` LzssHuffman
|
||||
- `0x0A0` XorLzssHuffman
|
||||
- `0x100` Deflate
|
||||
|
||||
## Модель ошибок
|
||||
|
||||
Типизированные ошибки без паник в production-коде (`InvalidMagic`, `UnsupportedVersion`, `EntryTableOutOfBounds`, `PackedSizePastEof`, `DeflateEofPlusOneQuirkRejected`, `UnsupportedMethod`, и др.).
|
||||
|
||||
## Покрытие тестами
|
||||
|
||||
### Реальные файлы
|
||||
|
||||
- Рекурсивный прогон по `testdata/rsli/**`.
|
||||
- Сейчас в наборе: **2 архива**.
|
||||
- На реальных данных подтверждены и проходят byte-to-byte проверки методы:
|
||||
- `0x040` (LZSS)
|
||||
- `0x100` (Deflate)
|
||||
- Для каждого архива проверяется:
|
||||
- `load`/`load_into`/`load_packed`/`unpack`/`load_fast`;
|
||||
- `find`;
|
||||
- пересборка и сравнение **byte-to-byte**.
|
||||
|
||||
### Синтетические тесты
|
||||
|
||||
Из-за отсутствия реальных файлов для части методов добавлены синтетические архивы и тесты:
|
||||
|
||||
- Методы:
|
||||
- `0x000`, `0x020`, `0x060`, `0x080`, `0x0A0`.
|
||||
- Спецкейсы формата:
|
||||
- AO trailer + overlay;
|
||||
- Deflate `EOF+1` (оба режима: accepted/rejected);
|
||||
- некорректные заголовки/таблицы/смещения/методы.
|
||||
|
||||
## Быстрый запуск тестов
|
||||
|
||||
```bash
|
||||
cargo test -p rsli -- --nocapture
|
||||
```
|
||||
14
crates/rsli/src/compress/deflate.rs
Normal file
14
crates/rsli/src/compress/deflate.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use crate::error::Error;
|
||||
use crate::Result;
|
||||
use flate2::read::DeflateDecoder;
|
||||
use std::io::Read;
|
||||
|
||||
/// Decode raw Deflate (RFC 1951) payload.
|
||||
pub fn decode_deflate(packed: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let mut decoder = DeflateDecoder::new(packed);
|
||||
decoder
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|_| Error::DecompressionFailed("deflate"))?;
|
||||
Ok(out)
|
||||
}
|
||||
298
crates/rsli/src/compress/lzh.rs
Normal file
298
crates/rsli/src/compress/lzh.rs
Normal file
@@ -0,0 +1,298 @@
|
||||
use super::xor::XorState;
|
||||
use crate::error::Error;
|
||||
use crate::Result;
|
||||
|
||||
pub(crate) const LZH_N: usize = 4096;
|
||||
pub(crate) const LZH_F: usize = 60;
|
||||
pub(crate) const LZH_THRESHOLD: usize = 2;
|
||||
pub(crate) const LZH_N_CHAR: usize = 256 - LZH_THRESHOLD + LZH_F;
|
||||
pub(crate) const LZH_T: usize = LZH_N_CHAR * 2 - 1;
|
||||
pub(crate) const LZH_R: usize = LZH_T - 1;
|
||||
pub(crate) const LZH_MAX_FREQ: u16 = 0x8000;
|
||||
|
||||
/// LZSS-Huffman decompression with optional on-the-fly XOR decryption.
|
||||
pub fn lzss_huffman_decompress(
|
||||
data: &[u8],
|
||||
expected_size: usize,
|
||||
xor_key: Option<u16>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut decoder = LzhDecoder::new(data, xor_key);
|
||||
decoder.decode(expected_size)
|
||||
}
|
||||
|
||||
struct LzhDecoder<'a> {
|
||||
bit_reader: BitReader<'a>,
|
||||
text: [u8; LZH_N],
|
||||
freq: [u16; LZH_T + 1],
|
||||
parent: [usize; LZH_T + LZH_N_CHAR],
|
||||
son: [usize; LZH_T],
|
||||
d_code: [u8; 256],
|
||||
d_len: [u8; 256],
|
||||
ring_pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> LzhDecoder<'a> {
|
||||
fn new(data: &'a [u8], xor_key: Option<u16>) -> Self {
|
||||
let mut decoder = Self {
|
||||
bit_reader: BitReader::new(data, xor_key),
|
||||
text: [0x20u8; LZH_N],
|
||||
freq: [0u16; LZH_T + 1],
|
||||
parent: [0usize; LZH_T + LZH_N_CHAR],
|
||||
son: [0usize; LZH_T],
|
||||
d_code: [0u8; 256],
|
||||
d_len: [0u8; 256],
|
||||
ring_pos: LZH_N - LZH_F,
|
||||
};
|
||||
decoder.init_tables();
|
||||
decoder.start_huff();
|
||||
decoder
|
||||
}
|
||||
|
||||
fn decode(&mut self, expected_size: usize) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(expected_size);
|
||||
|
||||
while out.len() < expected_size {
|
||||
let c = self.decode_char()?;
|
||||
if c < 256 {
|
||||
let byte = c as u8;
|
||||
out.push(byte);
|
||||
self.text[self.ring_pos] = byte;
|
||||
self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1);
|
||||
} else {
|
||||
let mut offset = self.decode_position()?;
|
||||
offset = (self.ring_pos.wrapping_sub(offset).wrapping_sub(1)) & (LZH_N - 1);
|
||||
let mut length = c.saturating_sub(253);
|
||||
|
||||
while length > 0 && out.len() < expected_size {
|
||||
let byte = self.text[offset];
|
||||
out.push(byte);
|
||||
self.text[self.ring_pos] = byte;
|
||||
self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1);
|
||||
offset = (offset + 1) & (LZH_N - 1);
|
||||
length -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if out.len() != expected_size {
|
||||
return Err(Error::DecompressionFailed("lzss-huffman"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn init_tables(&mut self) {
|
||||
let d_code_group_counts = [1usize, 3, 8, 12, 24, 16];
|
||||
let d_len_group_counts = [32usize, 48, 64, 48, 48, 16];
|
||||
|
||||
let mut group_index = 0u8;
|
||||
let mut idx = 0usize;
|
||||
let mut run = 32usize;
|
||||
for count in d_code_group_counts {
|
||||
for _ in 0..count {
|
||||
for _ in 0..run {
|
||||
self.d_code[idx] = group_index;
|
||||
idx += 1;
|
||||
}
|
||||
group_index = group_index.wrapping_add(1);
|
||||
}
|
||||
run >>= 1;
|
||||
}
|
||||
|
||||
let mut len = 3u8;
|
||||
idx = 0;
|
||||
for count in d_len_group_counts {
|
||||
for _ in 0..count {
|
||||
self.d_len[idx] = len;
|
||||
idx += 1;
|
||||
}
|
||||
len = len.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn start_huff(&mut self) {
|
||||
for i in 0..LZH_N_CHAR {
|
||||
self.freq[i] = 1;
|
||||
self.son[i] = i + LZH_T;
|
||||
self.parent[i + LZH_T] = i;
|
||||
}
|
||||
|
||||
let mut i = 0usize;
|
||||
let mut j = LZH_N_CHAR;
|
||||
while j <= LZH_R {
|
||||
self.freq[j] = self.freq[i].saturating_add(self.freq[i + 1]);
|
||||
self.son[j] = i;
|
||||
self.parent[i] = j;
|
||||
self.parent[i + 1] = j;
|
||||
i += 2;
|
||||
j += 1;
|
||||
}
|
||||
|
||||
self.freq[LZH_T] = u16::MAX;
|
||||
self.parent[LZH_R] = 0;
|
||||
}
|
||||
|
||||
fn decode_char(&mut self) -> Result<usize> {
|
||||
let mut node = self.son[LZH_R];
|
||||
while node < LZH_T {
|
||||
let bit = usize::from(self.bit_reader.read_bit()?);
|
||||
node = self.son[node + bit];
|
||||
}
|
||||
|
||||
let c = node - LZH_T;
|
||||
self.update(c);
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
fn decode_position(&mut self) -> Result<usize> {
|
||||
let i = self.bit_reader.read_bits(8)? as usize;
|
||||
let mut c = usize::from(self.d_code[i]) << 6;
|
||||
let mut j = usize::from(self.d_len[i]).saturating_sub(2);
|
||||
|
||||
while j > 0 {
|
||||
j -= 1;
|
||||
c |= usize::from(self.bit_reader.read_bit()?) << j;
|
||||
}
|
||||
|
||||
Ok(c | (i & 0x3F))
|
||||
}
|
||||
|
||||
fn update(&mut self, c: usize) {
|
||||
if self.freq[LZH_R] == LZH_MAX_FREQ {
|
||||
self.reconstruct();
|
||||
}
|
||||
|
||||
let mut current = self.parent[c + LZH_T];
|
||||
loop {
|
||||
self.freq[current] = self.freq[current].saturating_add(1);
|
||||
let freq = self.freq[current];
|
||||
|
||||
if current + 1 < self.freq.len() && freq > self.freq[current + 1] {
|
||||
let mut swap_idx = current + 1;
|
||||
while swap_idx + 1 < self.freq.len() && freq > self.freq[swap_idx + 1] {
|
||||
swap_idx += 1;
|
||||
}
|
||||
|
||||
self.freq.swap(current, swap_idx);
|
||||
|
||||
let left = self.son[current];
|
||||
let right = self.son[swap_idx];
|
||||
self.son[current] = right;
|
||||
self.son[swap_idx] = left;
|
||||
|
||||
self.parent[left] = swap_idx;
|
||||
if left < LZH_T {
|
||||
self.parent[left + 1] = swap_idx;
|
||||
}
|
||||
|
||||
self.parent[right] = current;
|
||||
if right < LZH_T {
|
||||
self.parent[right + 1] = current;
|
||||
}
|
||||
|
||||
current = swap_idx;
|
||||
}
|
||||
|
||||
current = self.parent[current];
|
||||
if current == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reconstruct(&mut self) {
|
||||
let mut j = 0usize;
|
||||
for i in 0..LZH_T {
|
||||
if self.son[i] >= LZH_T {
|
||||
self.freq[j] = (self.freq[i].saturating_add(1)) / 2;
|
||||
self.son[j] = self.son[i];
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut i = 0usize;
|
||||
let mut current = LZH_N_CHAR;
|
||||
while current < LZH_T {
|
||||
let sum = self.freq[i].saturating_add(self.freq[i + 1]);
|
||||
self.freq[current] = sum;
|
||||
|
||||
let mut insert_at = current;
|
||||
while insert_at > 0 && sum < self.freq[insert_at - 1] {
|
||||
insert_at -= 1;
|
||||
}
|
||||
|
||||
for move_idx in (insert_at..current).rev() {
|
||||
self.freq[move_idx + 1] = self.freq[move_idx];
|
||||
self.son[move_idx + 1] = self.son[move_idx];
|
||||
}
|
||||
|
||||
self.freq[insert_at] = sum;
|
||||
self.son[insert_at] = i;
|
||||
|
||||
i += 2;
|
||||
current += 1;
|
||||
}
|
||||
|
||||
for idx in 0..LZH_T {
|
||||
let node = self.son[idx];
|
||||
self.parent[node] = idx;
|
||||
if node < LZH_T {
|
||||
self.parent[node + 1] = idx;
|
||||
}
|
||||
}
|
||||
|
||||
self.freq[LZH_T] = u16::MAX;
|
||||
self.parent[LZH_R] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
struct BitReader<'a> {
|
||||
data: &'a [u8],
|
||||
byte_pos: usize,
|
||||
bit_mask: u8,
|
||||
current_byte: u8,
|
||||
xor_state: Option<XorState>,
|
||||
}
|
||||
|
||||
impl<'a> BitReader<'a> {
|
||||
fn new(data: &'a [u8], xor_key: Option<u16>) -> Self {
|
||||
Self {
|
||||
data,
|
||||
byte_pos: 0,
|
||||
bit_mask: 0x80,
|
||||
current_byte: 0,
|
||||
xor_state: xor_key.map(XorState::new),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_bit(&mut self) -> Result<u8> {
|
||||
if self.bit_mask == 0x80 {
|
||||
let Some(mut byte) = self.data.get(self.byte_pos).copied() else {
|
||||
return Err(Error::DecompressionFailed("lzss-huffman: unexpected EOF"));
|
||||
};
|
||||
if let Some(state) = &mut self.xor_state {
|
||||
byte = state.decrypt_byte(byte);
|
||||
}
|
||||
self.current_byte = byte;
|
||||
}
|
||||
|
||||
let bit = if (self.current_byte & self.bit_mask) != 0 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.bit_mask >>= 1;
|
||||
if self.bit_mask == 0 {
|
||||
self.bit_mask = 0x80;
|
||||
self.byte_pos = self.byte_pos.saturating_add(1);
|
||||
}
|
||||
Ok(bit)
|
||||
}
|
||||
|
||||
fn read_bits(&mut self, bits: usize) -> Result<u32> {
|
||||
let mut value = 0u32;
|
||||
for _ in 0..bits {
|
||||
value = (value << 1) | u32::from(self.read_bit()?);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
79
crates/rsli/src/compress/lzss.rs
Normal file
79
crates/rsli/src/compress/lzss.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use super::xor::XorState;
|
||||
use crate::error::Error;
|
||||
use crate::Result;
|
||||
|
||||
/// Simple LZSS decompression with optional on-the-fly XOR decryption
|
||||
pub fn lzss_decompress_simple(
|
||||
data: &[u8],
|
||||
expected_size: usize,
|
||||
xor_key: Option<u16>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut ring = [0x20u8; 0x1000];
|
||||
let mut ring_pos = 0xFEEusize;
|
||||
let mut out = Vec::with_capacity(expected_size);
|
||||
let mut in_pos = 0usize;
|
||||
|
||||
let mut control = 0u8;
|
||||
let mut bits_left = 0u8;
|
||||
|
||||
// XOR state for on-the-fly decryption
|
||||
let mut xor_state = xor_key.map(XorState::new);
|
||||
|
||||
// Helper to read byte with optional XOR decryption
|
||||
let read_byte = |pos: usize, state: &mut Option<XorState>| -> Option<u8> {
|
||||
let encrypted = data.get(pos).copied()?;
|
||||
Some(if let Some(ref mut s) = state {
|
||||
s.decrypt_byte(encrypted)
|
||||
} else {
|
||||
encrypted
|
||||
})
|
||||
};
|
||||
|
||||
while out.len() < expected_size {
|
||||
if bits_left == 0 {
|
||||
let byte = read_byte(in_pos, &mut xor_state)
|
||||
.ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
|
||||
control = byte;
|
||||
in_pos += 1;
|
||||
bits_left = 8;
|
||||
}
|
||||
|
||||
if (control & 1) != 0 {
|
||||
let byte = read_byte(in_pos, &mut xor_state)
|
||||
.ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
|
||||
in_pos += 1;
|
||||
|
||||
out.push(byte);
|
||||
ring[ring_pos] = byte;
|
||||
ring_pos = (ring_pos + 1) & 0x0FFF;
|
||||
} else {
|
||||
let low = read_byte(in_pos, &mut xor_state)
|
||||
.ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
|
||||
let high = read_byte(in_pos + 1, &mut xor_state)
|
||||
.ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
|
||||
in_pos += 2;
|
||||
|
||||
let offset = usize::from(low) | (usize::from(high & 0xF0) << 4);
|
||||
let length = usize::from((high & 0x0F) + 3);
|
||||
|
||||
for step in 0..length {
|
||||
let byte = ring[(offset + step) & 0x0FFF];
|
||||
out.push(byte);
|
||||
ring[ring_pos] = byte;
|
||||
ring_pos = (ring_pos + 1) & 0x0FFF;
|
||||
if out.len() >= expected_size {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
control >>= 1;
|
||||
bits_left -= 1;
|
||||
}
|
||||
|
||||
if out.len() != expected_size {
|
||||
return Err(Error::DecompressionFailed("lzss-simple"));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
9
crates/rsli/src/compress/mod.rs
Normal file
9
crates/rsli/src/compress/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod deflate;
|
||||
pub mod lzh;
|
||||
pub mod lzss;
|
||||
pub mod xor;
|
||||
|
||||
pub use deflate::decode_deflate;
|
||||
pub use lzh::lzss_huffman_decompress;
|
||||
pub use lzss::lzss_decompress_simple;
|
||||
pub use xor::{xor_stream, XorState};
|
||||
29
crates/rsli/src/compress/xor.rs
Normal file
29
crates/rsli/src/compress/xor.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
/// XOR cipher state for RsLi format
|
||||
pub struct XorState {
|
||||
lo: u8,
|
||||
hi: u8,
|
||||
}
|
||||
|
||||
impl XorState {
|
||||
/// Create new XOR state from 16-bit key
|
||||
pub fn new(key16: u16) -> Self {
|
||||
Self {
|
||||
lo: (key16 & 0xFF) as u8,
|
||||
hi: ((key16 >> 8) & 0xFF) as u8,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt a single byte and update state
|
||||
pub fn decrypt_byte(&mut self, encrypted: u8) -> u8 {
|
||||
self.lo = self.hi ^ self.lo.wrapping_shl(1);
|
||||
let decrypted = encrypted ^ self.lo;
|
||||
self.hi = self.lo ^ (self.hi >> 1);
|
||||
decrypted
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt entire buffer with XOR stream cipher
|
||||
pub fn xor_stream(data: &[u8], key16: u16) -> Vec<u8> {
|
||||
let mut state = XorState::new(key16);
|
||||
data.iter().map(|&b| state.decrypt_byte(b)).collect()
|
||||
}
|
||||
140
crates/rsli/src/error.rs
Normal file
140
crates/rsli/src/error.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use core::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
Io(std::io::Error),
|
||||
|
||||
InvalidMagic {
|
||||
got: [u8; 2],
|
||||
},
|
||||
UnsupportedVersion {
|
||||
got: u8,
|
||||
},
|
||||
InvalidEntryCount {
|
||||
got: i16,
|
||||
},
|
||||
TooManyEntries {
|
||||
got: usize,
|
||||
},
|
||||
|
||||
EntryTableOutOfBounds {
|
||||
table_offset: u64,
|
||||
table_len: u64,
|
||||
file_len: u64,
|
||||
},
|
||||
EntryTableDecryptFailed,
|
||||
CorruptEntryTable(&'static str),
|
||||
|
||||
EntryIdOutOfRange {
|
||||
id: u32,
|
||||
entry_count: u32,
|
||||
},
|
||||
EntryDataOutOfBounds {
|
||||
id: u32,
|
||||
offset: u64,
|
||||
size: u32,
|
||||
file_len: u64,
|
||||
},
|
||||
|
||||
AoTrailerInvalid,
|
||||
MediaOverlayOutOfBounds {
|
||||
overlay: u32,
|
||||
file_len: u64,
|
||||
},
|
||||
|
||||
UnsupportedMethod {
|
||||
raw: u32,
|
||||
},
|
||||
PackedSizePastEof {
|
||||
id: u32,
|
||||
offset: u64,
|
||||
packed_size: u32,
|
||||
file_len: u64,
|
||||
},
|
||||
DeflateEofPlusOneQuirkRejected {
|
||||
id: u32,
|
||||
},
|
||||
|
||||
DecompressionFailed(&'static str),
|
||||
OutputSizeMismatch {
|
||||
expected: u32,
|
||||
got: u32,
|
||||
},
|
||||
|
||||
IntegerOverflow,
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::Io(e) => write!(f, "I/O error: {e}"),
|
||||
Error::InvalidMagic { got } => write!(f, "invalid RsLi magic: {got:02X?}"),
|
||||
Error::UnsupportedVersion { got } => write!(f, "unsupported RsLi version: {got:#x}"),
|
||||
Error::InvalidEntryCount { got } => write!(f, "invalid entry_count: {got}"),
|
||||
Error::TooManyEntries { got } => write!(f, "too many entries: {got} exceeds u32::MAX"),
|
||||
Error::EntryTableOutOfBounds {
|
||||
table_offset,
|
||||
table_len,
|
||||
file_len,
|
||||
} => write!(
|
||||
f,
|
||||
"entry table out of bounds: off={table_offset}, len={table_len}, file={file_len}"
|
||||
),
|
||||
Error::EntryTableDecryptFailed => write!(f, "failed to decrypt entry table"),
|
||||
Error::CorruptEntryTable(s) => write!(f, "corrupt entry table: {s}"),
|
||||
Error::EntryIdOutOfRange { id, entry_count } => {
|
||||
write!(f, "entry id out of range: id={id}, count={entry_count}")
|
||||
}
|
||||
Error::EntryDataOutOfBounds {
|
||||
id,
|
||||
offset,
|
||||
size,
|
||||
file_len,
|
||||
} => write!(
|
||||
f,
|
||||
"entry data out of bounds: id={id}, off={offset}, size={size}, file={file_len}"
|
||||
),
|
||||
Error::AoTrailerInvalid => write!(f, "invalid AO trailer"),
|
||||
Error::MediaOverlayOutOfBounds { overlay, file_len } => {
|
||||
write!(
|
||||
f,
|
||||
"media overlay out of bounds: overlay={overlay}, file={file_len}"
|
||||
)
|
||||
}
|
||||
Error::UnsupportedMethod { raw } => write!(f, "unsupported packing method: {raw:#x}"),
|
||||
Error::PackedSizePastEof {
|
||||
id,
|
||||
offset,
|
||||
packed_size,
|
||||
file_len,
|
||||
} => write!(
|
||||
f,
|
||||
"packed range past EOF: id={id}, off={offset}, size={packed_size}, file={file_len}"
|
||||
),
|
||||
Error::DeflateEofPlusOneQuirkRejected { id } => {
|
||||
write!(f, "deflate EOF+1 quirk rejected for entry {id}")
|
||||
}
|
||||
Error::DecompressionFailed(s) => write!(f, "decompression failed: {s}"),
|
||||
Error::OutputSizeMismatch { expected, got } => {
|
||||
write!(f, "output size mismatch: expected={expected}, got={got}")
|
||||
}
|
||||
Error::IntegerOverflow => write!(f, "integer overflow"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
411
crates/rsli/src/lib.rs
Normal file
411
crates/rsli/src/lib.rs
Normal file
@@ -0,0 +1,411 @@
|
||||
pub mod compress;
|
||||
pub mod error;
|
||||
pub mod parse;
|
||||
|
||||
use crate::compress::{
|
||||
decode_deflate, lzss_decompress_simple, lzss_huffman_decompress, xor_stream,
|
||||
};
|
||||
use crate::error::Error;
|
||||
use crate::parse::{c_name_bytes, cmp_c_string, parse_library};
|
||||
use common::{OutputBuffer, ResourceData};
|
||||
use std::cmp::Ordering;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OpenOptions {
|
||||
pub allow_ao_trailer: bool,
|
||||
pub allow_deflate_eof_plus_one: bool,
|
||||
}
|
||||
|
||||
impl Default for OpenOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allow_ao_trailer: true,
|
||||
allow_deflate_eof_plus_one: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Library {
|
||||
bytes: Arc<[u8]>,
|
||||
entries: Vec<EntryRecord>,
|
||||
#[cfg(test)]
|
||||
pub(crate) header_raw: [u8; 32],
|
||||
#[cfg(test)]
|
||||
pub(crate) table_plain_original: Vec<u8>,
|
||||
#[cfg(test)]
|
||||
pub(crate) xor_seed: u32,
|
||||
#[cfg(test)]
|
||||
pub(crate) source_size: usize,
|
||||
#[cfg(test)]
|
||||
pub(crate) trailer_raw: Option<[u8; 6]>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct EntryId(pub u32);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EntryMeta {
|
||||
pub name: String,
|
||||
pub flags: i32,
|
||||
pub method: PackMethod,
|
||||
pub data_offset: u64,
|
||||
pub packed_size: u32,
|
||||
pub unpacked_size: u32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PackMethod {
|
||||
None,
|
||||
XorOnly,
|
||||
Lzss,
|
||||
XorLzss,
|
||||
LzssHuffman,
|
||||
XorLzssHuffman,
|
||||
Deflate,
|
||||
Unknown(u32),
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct EntryRef<'a> {
|
||||
pub id: EntryId,
|
||||
pub meta: &'a EntryMeta,
|
||||
}
|
||||
|
||||
pub struct PackedResource {
|
||||
pub meta: EntryMeta,
|
||||
pub packed: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct EntryRecord {
|
||||
pub(crate) meta: EntryMeta,
|
||||
pub(crate) name_raw: [u8; 12],
|
||||
pub(crate) sort_to_original: i16,
|
||||
pub(crate) key16: u16,
|
||||
#[cfg(test)]
|
||||
pub(crate) data_offset_raw: u32,
|
||||
pub(crate) packed_size_declared: u32,
|
||||
pub(crate) packed_size_available: usize,
|
||||
pub(crate) effective_offset: usize,
|
||||
}
|
||||
|
||||
impl Library {
|
||||
pub fn open_path(path: impl AsRef<Path>) -> Result<Self> {
|
||||
Self::open_path_with(path, OpenOptions::default())
|
||||
}
|
||||
|
||||
pub fn open_path_with(path: impl AsRef<Path>, opts: OpenOptions) -> Result<Self> {
|
||||
let bytes = fs::read(path.as_ref())?;
|
||||
let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice());
|
||||
parse_library(arc, opts)
|
||||
}
|
||||
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
||||
self.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, entry)| EntryRef {
|
||||
id: EntryId(u32::try_from(idx).expect("entry count validated at parse")),
|
||||
meta: &entry.meta,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find(&self, name: &str) -> Option<EntryId> {
|
||||
if self.entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
const MAX_INLINE_NAME: usize = 12;
|
||||
|
||||
// Fast path: use stack allocation for short ASCII names (95% of cases)
|
||||
if name.len() <= MAX_INLINE_NAME && name.is_ascii() {
|
||||
let mut buf = [0u8; MAX_INLINE_NAME];
|
||||
for (i, &b) in name.as_bytes().iter().enumerate() {
|
||||
buf[i] = b.to_ascii_uppercase();
|
||||
}
|
||||
return self.find_impl(&buf[..name.len()]);
|
||||
}
|
||||
|
||||
// Slow path: heap allocation for long or non-ASCII names
|
||||
let query = name.to_ascii_uppercase();
|
||||
self.find_impl(query.as_bytes())
|
||||
}
|
||||
|
||||
fn find_impl(&self, query_bytes: &[u8]) -> Option<EntryId> {
|
||||
// Binary search
|
||||
let mut low = 0usize;
|
||||
let mut high = self.entries.len();
|
||||
while low < high {
|
||||
let mid = low + (high - low) / 2;
|
||||
let idx = self.entries[mid].sort_to_original;
|
||||
if idx < 0 {
|
||||
break;
|
||||
}
|
||||
let idx = usize::try_from(idx).ok()?;
|
||||
if idx >= self.entries.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let cmp = cmp_c_string(query_bytes, c_name_bytes(&self.entries[idx].name_raw));
|
||||
match cmp {
|
||||
Ordering::Less => high = mid,
|
||||
Ordering::Greater => low = mid + 1,
|
||||
Ordering::Equal => {
|
||||
return Some(EntryId(
|
||||
u32::try_from(idx).expect("entry count validated at parse"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Linear fallback search
|
||||
self.entries.iter().enumerate().find_map(|(idx, entry)| {
|
||||
if cmp_c_string(query_bytes, c_name_bytes(&entry.name_raw)) == Ordering::Equal {
|
||||
Some(EntryId(
|
||||
u32::try_from(idx).expect("entry count validated at parse"),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(&self, id: EntryId) -> Option<EntryRef<'_>> {
|
||||
let idx = usize::try_from(id.0).ok()?;
|
||||
let entry = self.entries.get(idx)?;
|
||||
Some(EntryRef {
|
||||
id,
|
||||
meta: &entry.meta,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load(&self, id: EntryId) -> Result<Vec<u8>> {
|
||||
let entry = self.entry_by_id(id)?;
|
||||
let packed = self.packed_slice(id, entry)?;
|
||||
decode_payload(
|
||||
packed,
|
||||
entry.meta.method,
|
||||
entry.key16,
|
||||
entry.meta.unpacked_size,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn load_into(&self, id: EntryId, out: &mut dyn OutputBuffer) -> Result<usize> {
|
||||
let decoded = self.load(id)?;
|
||||
out.write_exact(&decoded)?;
|
||||
Ok(decoded.len())
|
||||
}
|
||||
|
||||
pub fn load_packed(&self, id: EntryId) -> Result<PackedResource> {
|
||||
let entry = self.entry_by_id(id)?;
|
||||
let packed = self.packed_slice(id, entry)?.to_vec();
|
||||
Ok(PackedResource {
|
||||
meta: entry.meta.clone(),
|
||||
packed,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn unpack(&self, packed: &PackedResource) -> Result<Vec<u8>> {
|
||||
let key16 = self.resolve_key_for_meta(&packed.meta).unwrap_or(0);
|
||||
|
||||
let method = packed.meta.method;
|
||||
if needs_xor_key(method) && self.resolve_key_for_meta(&packed.meta).is_none() {
|
||||
return Err(Error::CorruptEntryTable(
|
||||
"cannot resolve XOR key for packed resource",
|
||||
));
|
||||
}
|
||||
|
||||
decode_payload(&packed.packed, method, key16, packed.meta.unpacked_size)
|
||||
}
|
||||
|
||||
pub fn load_fast(&self, id: EntryId) -> Result<ResourceData<'_>> {
|
||||
let entry = self.entry_by_id(id)?;
|
||||
if entry.meta.method == PackMethod::None {
|
||||
let packed = self.packed_slice(id, entry)?;
|
||||
let size =
|
||||
usize::try_from(entry.meta.unpacked_size).map_err(|_| Error::IntegerOverflow)?;
|
||||
if packed.len() < size {
|
||||
return Err(Error::OutputSizeMismatch {
|
||||
expected: entry.meta.unpacked_size,
|
||||
got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
|
||||
});
|
||||
}
|
||||
return Ok(ResourceData::Borrowed(&packed[..size]));
|
||||
}
|
||||
Ok(ResourceData::Owned(self.load(id)?))
|
||||
}
|
||||
|
||||
fn entry_by_id(&self, id: EntryId) -> Result<&EntryRecord> {
|
||||
let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
|
||||
self.entries
|
||||
.get(idx)
|
||||
.ok_or_else(|| Error::EntryIdOutOfRange {
|
||||
id: id.0,
|
||||
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX),
|
||||
})
|
||||
}
|
||||
|
||||
fn packed_slice<'a>(&'a self, id: EntryId, entry: &EntryRecord) -> Result<&'a [u8]> {
|
||||
let start = entry.effective_offset;
|
||||
let end = start
|
||||
.checked_add(entry.packed_size_available)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
self.bytes
|
||||
.get(start..end)
|
||||
.ok_or(Error::EntryDataOutOfBounds {
|
||||
id: id.0,
|
||||
offset: u64::try_from(start).unwrap_or(u64::MAX),
|
||||
size: entry.packed_size_declared,
|
||||
file_len: u64::try_from(self.bytes.len()).unwrap_or(u64::MAX),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_key_for_meta(&self, meta: &EntryMeta) -> Option<u16> {
|
||||
self.entries
|
||||
.iter()
|
||||
.find(|entry| {
|
||||
entry.meta.name == meta.name
|
||||
&& entry.meta.flags == meta.flags
|
||||
&& entry.meta.data_offset == meta.data_offset
|
||||
&& entry.meta.packed_size == meta.packed_size
|
||||
&& entry.meta.unpacked_size == meta.unpacked_size
|
||||
&& entry.meta.method == meta.method
|
||||
})
|
||||
.map(|entry| entry.key16)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn rebuild_from_parsed_metadata(&self) -> Result<Vec<u8>> {
|
||||
let trailer_len = usize::from(self.trailer_raw.is_some()) * 6;
|
||||
let pre_trailer_size = self
|
||||
.source_size
|
||||
.checked_sub(trailer_len)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
|
||||
let count = self.entries.len();
|
||||
let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?;
|
||||
let table_end = 32usize
|
||||
.checked_add(table_len)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
if pre_trailer_size < table_end {
|
||||
return Err(Error::EntryTableOutOfBounds {
|
||||
table_offset: 32,
|
||||
table_len: u64::try_from(table_len).map_err(|_| Error::IntegerOverflow)?,
|
||||
file_len: u64::try_from(pre_trailer_size).map_err(|_| Error::IntegerOverflow)?,
|
||||
});
|
||||
}
|
||||
|
||||
let mut out = vec![0u8; pre_trailer_size];
|
||||
out[0..32].copy_from_slice(&self.header_raw);
|
||||
let encrypted_table =
|
||||
xor_stream(&self.table_plain_original, (self.xor_seed & 0xFFFF) as u16);
|
||||
out[32..table_end].copy_from_slice(&encrypted_table);
|
||||
|
||||
let mut occupied = vec![false; pre_trailer_size];
|
||||
for byte in occupied.iter_mut().take(table_end) {
|
||||
*byte = true;
|
||||
}
|
||||
|
||||
for (idx, entry) in self.entries.iter().enumerate() {
|
||||
let packed = self
|
||||
.load_packed(EntryId(
|
||||
u32::try_from(idx).expect("entry count validated at parse"),
|
||||
))?
|
||||
.packed;
|
||||
let start =
|
||||
usize::try_from(entry.data_offset_raw).map_err(|_| Error::IntegerOverflow)?;
|
||||
for (offset, byte) in packed.iter().copied().enumerate() {
|
||||
let pos = start.checked_add(offset).ok_or(Error::IntegerOverflow)?;
|
||||
if pos >= out.len() {
|
||||
return Err(Error::PackedSizePastEof {
|
||||
id: u32::try_from(idx).expect("entry count validated at parse"),
|
||||
offset: u64::from(entry.data_offset_raw),
|
||||
packed_size: entry.packed_size_declared,
|
||||
file_len: u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?,
|
||||
});
|
||||
}
|
||||
if occupied[pos] && out[pos] != byte {
|
||||
return Err(Error::CorruptEntryTable("packed payload overlap conflict"));
|
||||
}
|
||||
out[pos] = byte;
|
||||
occupied[pos] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(trailer) = self.trailer_raw {
|
||||
out.extend_from_slice(&trailer);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_payload(
|
||||
packed: &[u8],
|
||||
method: PackMethod,
|
||||
key16: u16,
|
||||
unpacked_size: u32,
|
||||
) -> Result<Vec<u8>> {
|
||||
let expected = usize::try_from(unpacked_size).map_err(|_| Error::IntegerOverflow)?;
|
||||
|
||||
let out = match method {
|
||||
PackMethod::None => {
|
||||
if packed.len() < expected {
|
||||
return Err(Error::OutputSizeMismatch {
|
||||
expected: unpacked_size,
|
||||
got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
|
||||
});
|
||||
}
|
||||
packed[..expected].to_vec()
|
||||
}
|
||||
PackMethod::XorOnly => {
|
||||
if packed.len() < expected {
|
||||
return Err(Error::OutputSizeMismatch {
|
||||
expected: unpacked_size,
|
||||
got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
|
||||
});
|
||||
}
|
||||
xor_stream(&packed[..expected], key16)
|
||||
}
|
||||
PackMethod::Lzss => lzss_decompress_simple(packed, expected, None)?,
|
||||
PackMethod::XorLzss => {
|
||||
// Optimized: XOR on-the-fly during decompression instead of creating temp buffer
|
||||
lzss_decompress_simple(packed, expected, Some(key16))?
|
||||
}
|
||||
PackMethod::LzssHuffman => lzss_huffman_decompress(packed, expected, None)?,
|
||||
PackMethod::XorLzssHuffman => {
|
||||
// Optimized: XOR on-the-fly during decompression
|
||||
lzss_huffman_decompress(packed, expected, Some(key16))?
|
||||
}
|
||||
PackMethod::Deflate => decode_deflate(packed)?,
|
||||
PackMethod::Unknown(raw) => return Err(Error::UnsupportedMethod { raw }),
|
||||
};
|
||||
|
||||
if out.len() != expected {
|
||||
return Err(Error::OutputSizeMismatch {
|
||||
expected: unpacked_size,
|
||||
got: u32::try_from(out.len()).unwrap_or(u32::MAX),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn needs_xor_key(method: PackMethod) -> bool {
|
||||
matches!(
|
||||
method,
|
||||
PackMethod::XorOnly | PackMethod::XorLzss | PackMethod::XorLzssHuffman
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
267
crates/rsli/src/parse.rs
Normal file
267
crates/rsli/src/parse.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
use crate::compress::xor::xor_stream;
|
||||
use crate::error::Error;
|
||||
use crate::{EntryMeta, EntryRecord, Library, OpenOptions, PackMethod, Result};
|
||||
use std::cmp::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
|
||||
if bytes.len() < 32 {
|
||||
return Err(Error::EntryTableOutOfBounds {
|
||||
table_offset: 32,
|
||||
table_len: 0,
|
||||
file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
|
||||
});
|
||||
}
|
||||
|
||||
let mut header_raw = [0u8; 32];
|
||||
header_raw.copy_from_slice(&bytes[0..32]);
|
||||
|
||||
if &bytes[0..2] != b"NL" {
|
||||
let mut got = [0u8; 2];
|
||||
got.copy_from_slice(&bytes[0..2]);
|
||||
return Err(Error::InvalidMagic { got });
|
||||
}
|
||||
if bytes[3] != 0x01 {
|
||||
return Err(Error::UnsupportedVersion { got: bytes[3] });
|
||||
}
|
||||
|
||||
let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]);
|
||||
if entry_count < 0 {
|
||||
return Err(Error::InvalidEntryCount { got: entry_count });
|
||||
}
|
||||
let count = usize::try_from(entry_count).map_err(|_| Error::IntegerOverflow)?;
|
||||
|
||||
// Validate entry_count fits in u32 (required for EntryId)
|
||||
if count > u32::MAX as usize {
|
||||
return Err(Error::TooManyEntries { got: count });
|
||||
}
|
||||
|
||||
let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
||||
|
||||
let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?;
|
||||
let table_offset = 32usize;
|
||||
let table_end = table_offset
|
||||
.checked_add(table_len)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
if table_end > bytes.len() {
|
||||
return Err(Error::EntryTableOutOfBounds {
|
||||
table_offset: u64::try_from(table_offset).map_err(|_| Error::IntegerOverflow)?,
|
||||
table_len: u64::try_from(table_len).map_err(|_| Error::IntegerOverflow)?,
|
||||
file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
|
||||
});
|
||||
}
|
||||
|
||||
let table_enc = &bytes[table_offset..table_end];
|
||||
let table_plain_original = xor_stream(table_enc, (xor_seed & 0xFFFF) as u16);
|
||||
if table_plain_original.len() != table_len {
|
||||
return Err(Error::EntryTableDecryptFailed);
|
||||
}
|
||||
|
||||
let (overlay, trailer_raw) = parse_ao_trailer(&bytes, opts.allow_ao_trailer)?;
|
||||
#[cfg(not(test))]
|
||||
let _ = trailer_raw;
|
||||
|
||||
let mut entries = Vec::with_capacity(count);
|
||||
for idx in 0..count {
|
||||
let row = &table_plain_original[idx * 32..(idx + 1) * 32];
|
||||
|
||||
let mut name_raw = [0u8; 12];
|
||||
name_raw.copy_from_slice(&row[0..12]);
|
||||
|
||||
let flags_signed = i16::from_le_bytes([row[16], row[17]]);
|
||||
let sort_to_original = i16::from_le_bytes([row[18], row[19]]);
|
||||
let unpacked_size = u32::from_le_bytes([row[20], row[21], row[22], row[23]]);
|
||||
let data_offset_raw = u32::from_le_bytes([row[24], row[25], row[26], row[27]]);
|
||||
let packed_size_declared = u32::from_le_bytes([row[28], row[29], row[30], row[31]]);
|
||||
|
||||
let method_raw = (flags_signed as u16 as u32) & 0x1E0;
|
||||
let method = parse_method(method_raw);
|
||||
|
||||
let effective_offset_u64 = u64::from(data_offset_raw)
|
||||
.checked_add(u64::from(overlay))
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
let effective_offset =
|
||||
usize::try_from(effective_offset_u64).map_err(|_| Error::IntegerOverflow)?;
|
||||
|
||||
let packed_size_usize =
|
||||
usize::try_from(packed_size_declared).map_err(|_| Error::IntegerOverflow)?;
|
||||
let mut packed_size_available = packed_size_usize;
|
||||
|
||||
let end = effective_offset_u64
|
||||
.checked_add(u64::from(packed_size_declared))
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
let file_len_u64 = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
|
||||
if end > file_len_u64 {
|
||||
if method_raw == 0x100 && end == file_len_u64 + 1 {
|
||||
if opts.allow_deflate_eof_plus_one {
|
||||
packed_size_available = packed_size_available
|
||||
.checked_sub(1)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
} else {
|
||||
return Err(Error::DeflateEofPlusOneQuirkRejected {
|
||||
id: u32::try_from(idx).expect("entry count validated at parse"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return Err(Error::PackedSizePastEof {
|
||||
id: u32::try_from(idx).expect("entry count validated at parse"),
|
||||
offset: effective_offset_u64,
|
||||
packed_size: packed_size_declared,
|
||||
file_len: file_len_u64,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let available_end = effective_offset
|
||||
.checked_add(packed_size_available)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
if available_end > bytes.len() {
|
||||
return Err(Error::EntryDataOutOfBounds {
|
||||
id: u32::try_from(idx).expect("entry count validated at parse"),
|
||||
offset: effective_offset_u64,
|
||||
size: packed_size_declared,
|
||||
file_len: file_len_u64,
|
||||
});
|
||||
}
|
||||
|
||||
let name = decode_name(c_name_bytes(&name_raw));
|
||||
|
||||
entries.push(EntryRecord {
|
||||
meta: EntryMeta {
|
||||
name,
|
||||
flags: i32::from(flags_signed),
|
||||
method,
|
||||
data_offset: effective_offset_u64,
|
||||
packed_size: packed_size_declared,
|
||||
unpacked_size,
|
||||
},
|
||||
name_raw,
|
||||
sort_to_original,
|
||||
key16: sort_to_original as u16,
|
||||
#[cfg(test)]
|
||||
data_offset_raw,
|
||||
packed_size_declared,
|
||||
packed_size_available,
|
||||
effective_offset,
|
||||
});
|
||||
}
|
||||
|
||||
let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
|
||||
if presorted_flag == 0xABBA {
|
||||
let mut seen = vec![false; count];
|
||||
for entry in &entries {
|
||||
let idx = i32::from(entry.sort_to_original);
|
||||
if idx < 0 {
|
||||
return Err(Error::CorruptEntryTable(
|
||||
"sort_to_original is not a valid permutation index",
|
||||
));
|
||||
}
|
||||
let idx = usize::try_from(idx).map_err(|_| Error::IntegerOverflow)?;
|
||||
if idx >= count {
|
||||
return Err(Error::CorruptEntryTable(
|
||||
"sort_to_original is not a valid permutation index",
|
||||
));
|
||||
}
|
||||
if seen[idx] {
|
||||
return Err(Error::CorruptEntryTable(
|
||||
"sort_to_original is not a permutation",
|
||||
));
|
||||
}
|
||||
seen[idx] = true;
|
||||
}
|
||||
if seen.iter().any(|value| !*value) {
|
||||
return Err(Error::CorruptEntryTable(
|
||||
"sort_to_original is not a permutation",
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let mut sorted: Vec<usize> = (0..count).collect();
|
||||
sorted.sort_by(|a, b| {
|
||||
cmp_c_string(
|
||||
c_name_bytes(&entries[*a].name_raw),
|
||||
c_name_bytes(&entries[*b].name_raw),
|
||||
)
|
||||
});
|
||||
for (idx, entry) in entries.iter_mut().enumerate() {
|
||||
entry.sort_to_original =
|
||||
i16::try_from(sorted[idx]).map_err(|_| Error::IntegerOverflow)?;
|
||||
entry.key16 = entry.sort_to_original as u16;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
let source_size = bytes.len();
|
||||
|
||||
Ok(Library {
|
||||
bytes,
|
||||
entries,
|
||||
#[cfg(test)]
|
||||
header_raw,
|
||||
#[cfg(test)]
|
||||
table_plain_original,
|
||||
#[cfg(test)]
|
||||
xor_seed,
|
||||
#[cfg(test)]
|
||||
source_size,
|
||||
#[cfg(test)]
|
||||
trailer_raw,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_ao_trailer(bytes: &[u8], allow: bool) -> Result<(u32, Option<[u8; 6]>)> {
|
||||
if !allow || bytes.len() < 6 {
|
||||
return Ok((0, None));
|
||||
}
|
||||
|
||||
if &bytes[bytes.len() - 6..bytes.len() - 4] != b"AO" {
|
||||
return Ok((0, None));
|
||||
}
|
||||
|
||||
let mut trailer = [0u8; 6];
|
||||
trailer.copy_from_slice(&bytes[bytes.len() - 6..]);
|
||||
let overlay = u32::from_le_bytes([trailer[2], trailer[3], trailer[4], trailer[5]]);
|
||||
|
||||
if u64::from(overlay) > u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)? {
|
||||
return Err(Error::MediaOverlayOutOfBounds {
|
||||
overlay,
|
||||
file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
|
||||
});
|
||||
}
|
||||
|
||||
Ok((overlay, Some(trailer)))
|
||||
}
|
||||
|
||||
pub fn parse_method(raw: u32) -> PackMethod {
|
||||
match raw {
|
||||
0x000 => PackMethod::None,
|
||||
0x020 => PackMethod::XorOnly,
|
||||
0x040 => PackMethod::Lzss,
|
||||
0x060 => PackMethod::XorLzss,
|
||||
0x080 => PackMethod::LzssHuffman,
|
||||
0x0A0 => PackMethod::XorLzssHuffman,
|
||||
0x100 => PackMethod::Deflate,
|
||||
other => PackMethod::Unknown(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_name(name: &[u8]) -> String {
|
||||
name.iter().map(|b| char::from(*b)).collect()
|
||||
}
|
||||
|
||||
pub fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
|
||||
let len = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
|
||||
&raw[..len]
|
||||
}
|
||||
|
||||
pub fn cmp_c_string(a: &[u8], b: &[u8]) -> Ordering {
|
||||
let min_len = a.len().min(b.len());
|
||||
let mut idx = 0usize;
|
||||
while idx < min_len {
|
||||
if a[idx] != b[idx] {
|
||||
return a[idx].cmp(&b[idx]);
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
a.len().cmp(&b.len())
|
||||
}
|
||||
1337
crates/rsli/src/tests.rs
Normal file
1337
crates/rsli/src/tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
5
docs/specs/ai.md
Normal file
5
docs/specs/ai.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# AI system
|
||||
|
||||
Документ описывает подсистему искусственного интеллекта: принятие решений, pathfinding и стратегическое поведение противников.
|
||||
|
||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ai.dll`.
|
||||
5
docs/specs/arealmap.md
Normal file
5
docs/specs/arealmap.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# ArealMap
|
||||
|
||||
Документ описывает формат и структуру карты мира: зоны/сектора, координаты, размещение объектов и связь с terrain и миссиями.
|
||||
|
||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ArealMap.dll`.
|
||||
5
docs/specs/behavior.md
Normal file
5
docs/specs/behavior.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Behavior system
|
||||
|
||||
Документ описывает поведенческую логику юнитов: state machine/behavior-паттерны, взаимодействия и базовые правила боевого поведения.
|
||||
|
||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Behavior.dll`.
|
||||
5
docs/specs/control.md
Normal file
5
docs/specs/control.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Control system
|
||||
|
||||
Документ описывает подсистему управления: mapping ввода (клавиатура, мышь, геймпад), обработку событий и буферизацию команд.
|
||||
|
||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Control.dll`.
|
||||
@@ -1,69 +0,0 @@
|
||||
# Эффекты и частицы
|
||||
|
||||
Пока что — **не байтовая спецификация**, а “карта” по тому, что видно в библиотеках. Полную документацию по эффектам/шейдерам/частицам можно будет сделать после того, как:
|
||||
|
||||
- найдём формат эффекта (файл/ресурс),
|
||||
- найдём точку загрузки/парсинга,
|
||||
- найдём точки рендера (создание буферов/вершинного формата/материалов).
|
||||
|
||||
---
|
||||
|
||||
## 1) Что видно по `Effect.dll`
|
||||
|
||||
- Есть экспорт `CreateFxManager(...)`, который создаёт менеджер эффектов и регистрирует его в движке.
|
||||
- Внутри много логики “сообщений/команд” через виртуальные вызовы (похоже на общий компонентный интерфейс).
|
||||
- Явного парсера формата эффекта (по типу “читать заголовок, читать эмиттеры…”) в найденных местах пока не идентифицировано.
|
||||
|
||||
---
|
||||
|
||||
## 2) Что видно по `Terrain.dll` (рендер‑статистика частиц)
|
||||
|
||||
В `Terrain.dll` есть отладочная/статистическая телеметрия:
|
||||
|
||||
- количество отрендеренных частиц (`Rendered particles`)
|
||||
- количество батчей (`Rendered batches`)
|
||||
- количество отрендеренных треугольников
|
||||
|
||||
Это подтверждает:
|
||||
|
||||
- частицы рендерятся батчами,
|
||||
- они интегрированы в общий 3D‑рендер (через тот же графический слой).
|
||||
|
||||
---
|
||||
|
||||
## 3) Что важно для совместимости
|
||||
|
||||
Даже без точного формата эффекта, из поведения оригинала следует:
|
||||
|
||||
- Эффекты/частицы завязаны на общий набор рендер‑фич (фильтрация/мультитекстурность/блендинг).
|
||||
- На слабом железе (и для минимализма) должны работать деградации:
|
||||
- без мипмапов,
|
||||
- без bilinear/trilinear,
|
||||
- без multitexturing,
|
||||
- возможно с 16‑бит текстурами.
|
||||
|
||||
---
|
||||
|
||||
## 4) План “докопать” до формата эффектов
|
||||
|
||||
1. Найти **точку создания эффекта по имени/ID**:
|
||||
- поискать места, где в строки/лог пишется имя эффекта,
|
||||
- найти функции, которые принимают “путь/имя” и возвращают handle.
|
||||
|
||||
2. Найти **точку загрузки данных**:
|
||||
- чтение из NRes/RsLi ресурса,
|
||||
- распаковка/декодирование.
|
||||
|
||||
3. Зафиксировать **структуру данных эффекта в памяти**:
|
||||
- эмиттеры,
|
||||
- спауны,
|
||||
- lifetime,
|
||||
- ключи размера/цвета,
|
||||
- привязка к текстурам/материалам.
|
||||
|
||||
4. Найти рендер‑код:
|
||||
- какой vertex format у частицы,
|
||||
- как формируются квадраты/ленты (billboard/trail),
|
||||
- какие state’ы включаются.
|
||||
|
||||
После этого можно будет выпустить полноценный документ “FX format”.
|
||||
834
docs/specs/fxid.md
Normal file
834
docs/specs/fxid.md
Normal file
@@ -0,0 +1,834 @@
|
||||
# FXID
|
||||
|
||||
Документ фиксирует спецификацию ресурса эффекта `FXID` на уровне, достаточном для:
|
||||
|
||||
- 1:1 загрузки и исполнения в совместимом runtime;
|
||||
- построения валидатора payload;
|
||||
- создания lossless-конвертера (`binary -> IR -> binary`);
|
||||
- создания редактора с безопасным редактированием полей.
|
||||
|
||||
Связанный контейнер: [NRes / RsLi](nres.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Источники и статус восстановления
|
||||
|
||||
Спецификация восстановлена по:
|
||||
|
||||
- `tmp/disassembler1/Effect.dll.c`;
|
||||
- `tmp/disassembler2/Effect.dll.asm`;
|
||||
- интеграционным вызовам из `tmp/disassembler1/Terrain.dll.c`;
|
||||
- проверке реальных архивов `testdata/nres`.
|
||||
|
||||
Ключевые функции:
|
||||
|
||||
- parser FXID: `Effect.dll!sub_10007650`;
|
||||
- runtime loop: `sub_10003D30(case 28)`, `sub_10006170`, `sub_10008120`, `sub_10007D10`;
|
||||
- alpha/time: `sub_10005C60`;
|
||||
- exports: `CreateFxManager`, `InitializeSettings`.
|
||||
|
||||
Проверка по данным:
|
||||
|
||||
- `923/923` FXID payload валидны в `testdata/nres`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Контейнер и runtime API
|
||||
|
||||
### 2.1. NRes entry
|
||||
|
||||
FXID хранится как NRes-entry:
|
||||
|
||||
- `type_id = 0x44495846` (`"FXID"`).
|
||||
|
||||
Наблюдение по датасету (923 эффекта):
|
||||
|
||||
- `attr1 = 0`, `attr2 = 0`, `attr3 = 1`.
|
||||
|
||||
### 2.2. Export API `Effect.dll`
|
||||
|
||||
Экспортируются:
|
||||
|
||||
- `CreateFxManager(int a1, int a2, int owner)`;
|
||||
- `InitializeSettings()`.
|
||||
|
||||
`CreateFxManager` создаёт manager-объект (`0xB8` байт), инициализирует через `sub_10003AE0`, возвращает интерфейсный указатель (`base + 4`).
|
||||
|
||||
### 2.3. Интерфейс менеджера
|
||||
|
||||
Рабочая vtable (`off_1001E478`):
|
||||
|
||||
| Смещение | Функция | Назначение |
|
||||
|---|---|---|
|
||||
| +0x08 | `sub_10003D30` | Event dispatcher (`4/20/23/24/28`) |
|
||||
| +0x10 | `sub_10004320` | Открыть/закэшировать FX resource |
|
||||
| +0x14 | `sub_10004590` | Создать runtime instance |
|
||||
| +0x18 | `sub_10004780` | Удалить instance |
|
||||
| +0x1C | `sub_100047B0` | Установить time/interp mode |
|
||||
| +0x20 | `sub_100047D0` | Установить scale |
|
||||
| +0x24 | `sub_10004830` | Установить позицию |
|
||||
| +0x28 | `sub_10004930` | Установить matrix transform |
|
||||
| +0x2C | `sub_10004B00` | Restart/retime |
|
||||
| +0x38 | `sub_10004BA0` | Duration modifier |
|
||||
| +0x3C | `sub_10004BD0` | Start/Enable |
|
||||
| +0x40 | `sub_10004C10` | Stop/Disable |
|
||||
| +0x44 | `sub_10004C50` | Bind emitter/context |
|
||||
| +0x48 | `sub_10004D50` | Сброс frame flags |
|
||||
|
||||
`Terrain.dll` использует `QueryInterface(id=19)` для получения рабочего интерфейса.
|
||||
|
||||
---
|
||||
|
||||
## 3. Бинарный формат FXID payload
|
||||
|
||||
Все значения little-endian.
|
||||
|
||||
### 3.1. Header (60 байт, `0x3C`)
|
||||
|
||||
```c
|
||||
struct FxHeader60 {
|
||||
uint32_t cmd_count; // 0x00
|
||||
uint32_t time_mode; // 0x04
|
||||
float duration_sec; // 0x08
|
||||
float phase_jitter; // 0x0C
|
||||
uint32_t flags; // 0x10
|
||||
uint32_t settings_id; // 0x14
|
||||
float rand_shift_x; // 0x18
|
||||
float rand_shift_y; // 0x1C
|
||||
float rand_shift_z; // 0x20
|
||||
float pivot_x; // 0x24
|
||||
float pivot_y; // 0x28
|
||||
float pivot_z; // 0x2C
|
||||
float scale_x; // 0x30
|
||||
float scale_y; // 0x34
|
||||
float scale_z; // 0x38
|
||||
};
|
||||
```
|
||||
|
||||
Командный поток начинается строго с `offset = 0x3C`.
|
||||
|
||||
### 3.2. Header-поля (подтвержденная семантика)
|
||||
|
||||
- `cmd_count`: число команд (engine итерирует ровно столько шагов).
|
||||
- `time_mode`: базовый режим вычисления alpha/time (`sub_10005C60`).
|
||||
- `duration_sec`: в runtime -> `duration_ms = duration_sec * 1000`.
|
||||
- `phase_jitter`: используется при `flags & 0x1`.
|
||||
- `flags`: runtime-gating/alpha/visibility (см. ниже).
|
||||
- `settings_id`: в `sub_1000EC40` используется `settings_id & 0xFF`.
|
||||
- `rand_shift_*`: используется при `flags & 0x8`.
|
||||
- `pivot_*`: используется в ветках `sub_10007D10`.
|
||||
- `scale_*`: копируется в runtime scale и влияет на матрицы.
|
||||
|
||||
### 3.3. `flags` (битовая карта)
|
||||
|
||||
| Бит | Маска | Наблюдаемое поведение |
|
||||
|---|---:|---|
|
||||
| 0 | `0x0001` | Random phase jitter (`phase_jitter`) |
|
||||
| 3 | `0x0008` | Random positional shift (`rand_shift_*`) |
|
||||
| 4 | `0x0010` | Visibility/occlusion ветки |
|
||||
| 5 | `0x0020` | Triangular remap в `sub_10005C60` |
|
||||
| 6 | `0x0040` | Инверсия начального active-state |
|
||||
| 7 | `0x0080` | Day/night filter (ветка A) |
|
||||
| 8 | `0x0100` | Day/night filter (ветка B, инверсия) |
|
||||
| 9 | `0x0200` | Alpha *= normalized lifetime |
|
||||
| 10 | `0x0400` | Установка manager bit1 (`+0xA0`) |
|
||||
| 11 | `0x0800` | Изменение gating в `sub_10007D10` |
|
||||
| 12 | `0x1000` | Установка manager-state bit `0x10` |
|
||||
|
||||
Нерасшифрованные биты должны сохраняться 1:1.
|
||||
|
||||
### 3.4. `time_mode` (`0..17`)
|
||||
|
||||
Обозначения (`sub_10005C60`):
|
||||
|
||||
- `t0 = instance.start_ms`, `t1 = instance.end_ms`;
|
||||
- `tn = (now_ms - t0) / (t1 - t0)`;
|
||||
- `prev = instance.cached_alpha` (`v4+52` в дизассембле).
|
||||
|
||||
Режимы:
|
||||
|
||||
- `0`: constant (`instance.alpha_const`, поле `v4+40`);
|
||||
- `1`: `tn`;
|
||||
- `2`: `fract(tn)`;
|
||||
- `3`: `1 - tn`;
|
||||
- `4`: external value из queue/world API (manager `+36`, id из `this+104[a2]`);
|
||||
- `5`: `|param33.xyz| / |param17.vecA.xyz|`;
|
||||
- `6`: `param33.x / param17.vecA.x`;
|
||||
- `7`: `param33.y / param17.vecA.y`;
|
||||
- `8`: `param33.z / param17.vecA.z`;
|
||||
- `9`: `|param36.xyz| / |param17.vecB.xyz|`;
|
||||
- `10`: `param36.x / param17.vecB.x`;
|
||||
- `11`: `param36.y / param17.vecB.y`;
|
||||
- `12`: `param36.z / param17.vecB.z`;
|
||||
- `13`: `1 - external_resource_value`;
|
||||
- `14`: `1 - queue_param(49)`;
|
||||
- `15`: `max(norm(param33/vecA), norm(param36/vecB))`;
|
||||
- `16`: external (`mode 4`) с нижним clamp к `prev` (`0` не зажимается);
|
||||
- `17`: external (`mode 4`) с верхним clamp к `prev` (`1` не зажимается).
|
||||
|
||||
Post-обработка после mode:
|
||||
|
||||
- если `flags & 0x200`: `alpha *= tn`;
|
||||
- если `flags & 0x20`: triangular remap (`alpha = (alpha < 0.5 ? alpha : 1-alpha) * 2`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Командный поток
|
||||
|
||||
### 4.1. Общий формат команды
|
||||
|
||||
Каждая команда:
|
||||
|
||||
- `uint32 cmd_word`;
|
||||
- далее body фиксированного размера по opcode.
|
||||
|
||||
`cmd_word`:
|
||||
|
||||
- `opcode = cmd_word & 0xFF`;
|
||||
- `enabled = (cmd_word >> 8) & 1`;
|
||||
- `bits 9..31` в датасете нулевые, но их надо сохранять 1:1.
|
||||
|
||||
Выравнивания между командами нет.
|
||||
|
||||
### 4.2. Размеры
|
||||
|
||||
| Opcode | Размер записи |
|
||||
|---:|---:|
|
||||
| 1 | 224 |
|
||||
| 2 | 148 |
|
||||
| 3 | 200 |
|
||||
| 4 | 204 |
|
||||
| 5 | 112 |
|
||||
| 6 | 4 |
|
||||
| 7 | 208 |
|
||||
| 8 | 248 |
|
||||
| 9 | 208 |
|
||||
| 10 | 208 |
|
||||
|
||||
### 4.3. Opcode -> runtime-класс (vtable)
|
||||
|
||||
| Opcode | `new(size)` | vtable |
|
||||
|---:|---:|---|
|
||||
| 1 | `0xF0` | `off_1001E78C` |
|
||||
| 2 | `0xA0` | `off_1001F048` |
|
||||
| 3 | `0xFC` | `off_1001E770` |
|
||||
| 4 | `0x104` | `off_1001E754` |
|
||||
| 5 | `0x54` | `off_1001E360` |
|
||||
| 6 | `0x1C` | `off_1001E738` |
|
||||
| 7 | `0x48` | `off_1001E228` |
|
||||
| 8 | `0xAC` | `off_1001E71C` |
|
||||
| 9 | `0x100` | `off_1001E700` |
|
||||
| 10 | `0x48` | `off_1001E24C` |
|
||||
|
||||
### 4.4. Общий вызовной контракт команды
|
||||
|
||||
После создания команды (`sub_10007650`):
|
||||
|
||||
1. `cmd->enabled = cmd_word.bit8`.
|
||||
2. `cmd->Init(fx_queue, fx_instance)` (`vfunc +4`).
|
||||
3. команда добавляется в список инстанса.
|
||||
|
||||
В runtime cycle:
|
||||
|
||||
- `vfunc +8`: update/compute (bool);
|
||||
- `vfunc +12`: emission/render callback;
|
||||
- `vfunc +20`: toggle active;
|
||||
- `vfunc +16`/`+24`: служебные функции (зависят от opcode).
|
||||
|
||||
---
|
||||
|
||||
## 5. Загрузка FXID (engine-accurate)
|
||||
|
||||
`sub_10007650`:
|
||||
|
||||
```c
|
||||
void FxLoad(FxInstance* fx, uint8_t* payload) {
|
||||
FxHeader60* h = (FxHeader60*)payload;
|
||||
|
||||
fx->raw_header = h;
|
||||
fx->mode = h->time_mode;
|
||||
fx->end_ms = fx->start_ms + h->duration_sec * 1000.0f;
|
||||
fx->scale = {h->scale_x, h->scale_y, h->scale_z};
|
||||
fx->active_default = ((h->flags & 0x40) == 0);
|
||||
|
||||
uint8_t* ptr = payload + 0x3C;
|
||||
for (uint32_t i = 0; i < h->cmd_count; ++i) {
|
||||
uint32_t w = *(uint32_t*)ptr;
|
||||
uint8_t op = (uint8_t)(w & 0xFF);
|
||||
|
||||
Command* cmd = CreateByOpcode(op, ptr); // может вернуть null
|
||||
if (cmd) {
|
||||
cmd->enabled = (w >> 8) & 1;
|
||||
|
||||
if (h->flags & 0x400) fx->manager_flags |= 0x0100;
|
||||
if ((h->flags & 0x400) || cmd->enabled) fx->manager_flags |= 0x0010;
|
||||
|
||||
cmd->Init(fx->queue, fx);
|
||||
fx->commands.push_back(cmd);
|
||||
}
|
||||
|
||||
ptr += size_by_opcode(op); // без bounds checks в оригинале
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Критичные edge-case оригинала:
|
||||
|
||||
- bounds checks отсутствуют;
|
||||
- при unknown opcode `ptr` не двигается (`advance = 0`);
|
||||
- при `new == null` команда пропускается, но `ptr` двигается.
|
||||
|
||||
Фактический `advance` в `sub_10007650` задан hardcoded в DWORD:
|
||||
|
||||
- `op1:+56`, `op2:+37`, `op3:+50`, `op4:+51`, `op5:+28`,
|
||||
- `op6:+1`, `op7:+52`, `op8:+62`, `op9:+52`, `op10:+52`,
|
||||
- `default:+0`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Runtime lifecycle
|
||||
|
||||
- `sub_10007470`: ctor instance.
|
||||
- `sub_10003D30(case 28)`: per-frame update manager.
|
||||
- `sub_10006170`: gate + alpha/time + command updates.
|
||||
- `sub_10008120` / `sub_10007D10`: update/render branches.
|
||||
- Start/Stop: `sub_10004BD0` / `sub_10004C10`.
|
||||
|
||||
Event-codes `sub_10003D30`:
|
||||
|
||||
- `4`: bootstrap/time init;
|
||||
- `20`: range-removal + index repair;
|
||||
- `23`: set manager bit0;
|
||||
- `24`: clear manager bit0;
|
||||
- `28`: main tick.
|
||||
|
||||
---
|
||||
|
||||
## 7. Общий тип `ResourceRef64`
|
||||
|
||||
Для opcode `2/3/4/5/7/8/9/10` присутствует ссылка вида:
|
||||
|
||||
```c
|
||||
struct ResourceRef64 {
|
||||
char archive[32]; // null-terminated ASCII, case-insensitive compare
|
||||
char name[32]; // null-terminated ASCII
|
||||
};
|
||||
```
|
||||
|
||||
Поведение loader'а:
|
||||
|
||||
- оба имени обязаны быть непустыми;
|
||||
- кэширование по `(_strcmpi archive, _strcmpi name)`;
|
||||
- загрузка/резолв через manager resource API.
|
||||
|
||||
Наблюдение по данным:
|
||||
|
||||
- для `opcode 2`: обычно `sounds.lib` + `*.wav`;
|
||||
- для остальных: обычно `material.lib` + material name.
|
||||
|
||||
---
|
||||
|
||||
## 8. Полная карта body по opcode (field-level)
|
||||
|
||||
Смещения указаны от начала команды (включая `cmd_word`).
|
||||
|
||||
### 8.1. Opcode 1 (`off_1001E78C`, size=224)
|
||||
|
||||
Основные методы:
|
||||
|
||||
- init: `sub_1000F4B0`;
|
||||
- update: `sub_1000F6E0`;
|
||||
- emit: `nullsub_2`;
|
||||
- toggle: `sub_1000F490`.
|
||||
|
||||
```c
|
||||
struct FxCmd01 {
|
||||
uint32_t word; // +0
|
||||
uint32_t mode; // +4 (enum, см. ниже)
|
||||
float t_start; // +8
|
||||
float t_end; // +12
|
||||
|
||||
float p0_min[3]; // +16..24
|
||||
float p0_max[3]; // +28..36
|
||||
|
||||
float p1_min[3]; // +40..48
|
||||
float p1_max[3]; // +52..60
|
||||
|
||||
float q0_min[4]; // +64..76
|
||||
float q0_max[4]; // +80..92
|
||||
|
||||
float q0_rand_span[4]; // +96..108 (все 4 читаются в sub_1000F6E0)
|
||||
|
||||
float scalar_min; // +112
|
||||
float scalar_max; // +116
|
||||
float scalar_rand_amp; // +120
|
||||
|
||||
float color_rgb[3]; // +124..132 (вызов manager+16)
|
||||
|
||||
float opaque_tail6[6]; // +136..156 (сохранять 1:1; в датасете почти всегда 0)
|
||||
|
||||
char opt_archive[32]; // +160..191 (редко, напр. "material.lib")
|
||||
char opt_name[32]; // +192..223 (редко, напр. "light_w")
|
||||
};
|
||||
```
|
||||
|
||||
Замечания по полям op1:
|
||||
|
||||
- `+108` не резерв: участвует в random-выборке как 4-я компонента блока `+96..108`;
|
||||
- `+136..156` не читается vtable-методами класса `off_1001E78C` в `Effect.dll` (init/update/toggle/accessor), но должно сохраняться 1:1;
|
||||
- редкий кейс с ненулевыми `+136..156` и строками `+160/+192` зафиксирован в `effects.rlb:r_lightray_w`.
|
||||
|
||||
`mode` (`+4`) -> параметры вызова manager (`sub_1000F4B0`):
|
||||
|
||||
- `1 -> create_kind=1, flags=0x80000000`;
|
||||
- `2/5 -> create_kind=1, flags=0x00000000`;
|
||||
- `3 -> create_kind=3, flags=0x00000000`;
|
||||
- `4 -> create_kind=4, flags=0x00000000`;
|
||||
- `6 -> create_kind=1, flags=0xA0000000`;
|
||||
- `7 -> create_kind=1, flags=0x20000000`.
|
||||
|
||||
### 8.2. Opcode 2 (`off_1001F048`, size=148)
|
||||
|
||||
Основные методы:
|
||||
|
||||
- init: `sub_10012D10`;
|
||||
- update: `sub_10012EB0`;
|
||||
- emit: `nullsub_2`;
|
||||
- toggle: `sub_10013170`.
|
||||
|
||||
```c
|
||||
struct FxCmd02 {
|
||||
uint32_t word; // +0
|
||||
uint32_t mode; // +4 (0..3; влияет на sub_100065A0 mapping)
|
||||
float t_start; // +8
|
||||
float t_end; // +12
|
||||
|
||||
float a_min[3]; // +16..24
|
||||
float a_max[3]; // +28..36
|
||||
|
||||
float b_min[3]; // +40..48
|
||||
float b_max[3]; // +52..60
|
||||
|
||||
float c0_base; // +64
|
||||
float c1_base; // +68
|
||||
float c2_base; // +72
|
||||
float c2_max; // +76
|
||||
|
||||
uint32_t param_910; // +80 (передаётся в manager cmd=910)
|
||||
|
||||
ResourceRef64 ref; // +84..147 (обычно sounds.lib + wav)
|
||||
};
|
||||
```
|
||||
|
||||
`mode` -> внутренний map в `sub_100065A0`:
|
||||
|
||||
- `0 -> 0`, `1 -> 512`, `2 -> 2`, `3 -> 514`.
|
||||
|
||||
### 8.3. Opcode 3 (`off_1001E770`, size=200)
|
||||
|
||||
Методы:
|
||||
|
||||
- init: `sub_100103B0`;
|
||||
- update: `sub_100105F0`;
|
||||
- emit: `sub_100106C0`.
|
||||
|
||||
```c
|
||||
struct FxCmd03 {
|
||||
uint32_t word; // +0
|
||||
uint32_t mode; // +4
|
||||
|
||||
float alpha_source; // +8 (>=0: norm time, <0: global time)
|
||||
float alpha_pow_a; // +12
|
||||
float alpha_pow_b; // +16
|
||||
|
||||
float out_min; // +20
|
||||
float out_max; // +24
|
||||
float out_pow; // +28
|
||||
|
||||
float active_t0; // +32
|
||||
float active_t1; // +36
|
||||
|
||||
float v0_min[3]; // +40..48
|
||||
float v0_max[3]; // +52..60
|
||||
|
||||
float pow0[3]; // +64..72
|
||||
|
||||
float v1_min[3]; // +76..84
|
||||
float v1_max[3]; // +88..96
|
||||
|
||||
float v2_min[3]; // +100..108
|
||||
float v2_max[3]; // +112..120
|
||||
|
||||
float pow1[3]; // +124..132
|
||||
|
||||
ResourceRef64 ref; // +136..199
|
||||
};
|
||||
```
|
||||
|
||||
### 8.4. Opcode 4 (`off_1001E754`, size=204)
|
||||
|
||||
Layout как opcode 3 + последний коэффициент:
|
||||
|
||||
```c
|
||||
struct FxCmd04 {
|
||||
FxCmd03 base; // +0..199
|
||||
float dist_norm_inv_base; // +200 (используется в sub_100108C0/100109B0)
|
||||
};
|
||||
```
|
||||
|
||||
`sub_100108C0`: `obj->inv = 1.0 / raw[200]`.
|
||||
|
||||
### 8.5. Opcode 5 (`off_1001E360`, size=112)
|
||||
|
||||
Методы:
|
||||
|
||||
- init: `sub_100028A0`;
|
||||
- update: `sub_10002A20`;
|
||||
- emit: `sub_10002BE0`;
|
||||
- context update: `sub_10003070`.
|
||||
|
||||
```c
|
||||
struct FxCmd05 {
|
||||
uint32_t word; // +0
|
||||
uint32_t mode; // +4 (в данных обычно 1)
|
||||
uint32_t unused_08; // +8 (в текущем коде opcode5 не читается)
|
||||
uint32_t unused_0C; // +12 (в текущем коде opcode5 не читается)
|
||||
|
||||
float active_t0; // +16
|
||||
uint32_t max_segments; // +20
|
||||
float active_t1_min; // +24
|
||||
float active_t1_max; // +28
|
||||
|
||||
float step_norm; // +32
|
||||
float segment_len; // +36
|
||||
float alpha_source; // +40 (>=0 norm, <0 random)
|
||||
float alpha_pow; // +44
|
||||
|
||||
ResourceRef64 ref; // +48..111
|
||||
};
|
||||
```
|
||||
|
||||
### 8.6. Opcode 6 (`off_1001E738`, size=4)
|
||||
|
||||
Только `cmd_word`:
|
||||
|
||||
```c
|
||||
struct FxCmd06 {
|
||||
uint32_t word; // +0
|
||||
};
|
||||
```
|
||||
|
||||
`init/update/emit` фактически no-op (`sub_100030B0` возвращает `0`).
|
||||
|
||||
### 8.7. Opcode 7 (`off_1001E228`, size=208)
|
||||
|
||||
Методы:
|
||||
|
||||
- init: `sub_10001720`;
|
||||
- update: `sub_10001230`;
|
||||
- emit: `sub_10001300`;
|
||||
- element accessor: `sub_10002780`.
|
||||
|
||||
```c
|
||||
struct FxCmd07 {
|
||||
uint32_t word; // +0
|
||||
uint32_t mode; // +4
|
||||
|
||||
float eval_min; // +8
|
||||
float eval_max; // +12
|
||||
float eval_pow; // +16
|
||||
|
||||
float active_t0; // +20
|
||||
float active_t1; // +24
|
||||
|
||||
float phase_span; // +28
|
||||
float phase_rate; // +32
|
||||
|
||||
uint32_t count_a; // +36
|
||||
uint32_t count_b; // +40
|
||||
|
||||
float set0_min[3]; // +44..52
|
||||
float set0_max[3]; // +56..64
|
||||
float set0_rand[3]; // +68..76
|
||||
float set0_pow[3]; // +80..88
|
||||
|
||||
float set1_min[3]; // +92..100
|
||||
float set1_max[3]; // +104..112
|
||||
float set1_rand[3]; // +116..124
|
||||
float set1_pow[3]; // +128..136
|
||||
|
||||
float gravity_or_drag_k; // +140
|
||||
|
||||
ResourceRef64 ref; // +144..207
|
||||
};
|
||||
```
|
||||
|
||||
### 8.8. Opcode 8 (`off_1001E71C`, size=248)
|
||||
|
||||
Методы:
|
||||
|
||||
- init: `sub_10011230`;
|
||||
- update: `sub_100115C0`;
|
||||
- emit: `sub_10012030`.
|
||||
|
||||
```c
|
||||
struct FxCmd08 {
|
||||
uint32_t word; // +0
|
||||
uint32_t mode; // +4
|
||||
|
||||
float eval_t0; // +8
|
||||
float eval_t1; // +12
|
||||
|
||||
float gate_t0; // +16
|
||||
float gate_t1; // +20
|
||||
|
||||
float period_min; // +24
|
||||
float period_max; // +28
|
||||
float phase_pow; // +32
|
||||
|
||||
uint32_t slots; // +36
|
||||
|
||||
float set0_min[3]; // +40..48
|
||||
float set0_max[3]; // +52..60
|
||||
float set0_rand[3]; // +64..72
|
||||
|
||||
float set1_min[3]; // +76..84
|
||||
float set1_max[3]; // +88..96
|
||||
float set1_rand[3]; // +100..108
|
||||
|
||||
float set2_rand[3]; // +112..120
|
||||
float set2_pow[3]; // +124..132
|
||||
|
||||
float rmax_set0[3]; // +136..144 (bound/radius calc)
|
||||
float rmax_set1[3]; // +148..156 (bound/radius calc)
|
||||
float rmax_set2[3]; // +160..168 (bound/radius calc)
|
||||
|
||||
float render_pow[3]; // +172..180
|
||||
|
||||
ResourceRef64 ref; // +184..247
|
||||
};
|
||||
```
|
||||
|
||||
### 8.9. Opcode 9 (`off_1001E700`, size=208)
|
||||
|
||||
Layout как opcode 3 с двумя final-полями:
|
||||
|
||||
```c
|
||||
struct FxCmd09 {
|
||||
FxCmd03 base; // +0..199
|
||||
uint32_t render_kind; // +200 (0/1/2 -> 3/5/6 in sub_100138C0)
|
||||
uint32_t render_flag; // +204 (0 -> добавляет bit 0x08000000)
|
||||
};
|
||||
```
|
||||
|
||||
Методы:
|
||||
|
||||
- init/update как у opcode 3 (`sub_100103B0`, `sub_100105F0`);
|
||||
- emit: `sub_100138C0` -> формирует код рендера и вызывает `sub_100106C0`.
|
||||
|
||||
### 8.10. Opcode 10 (`off_1001E24C`, size=208)
|
||||
|
||||
Body-layout совпадает с opcode 7 (`FxCmd07`), но другой runtime класс.
|
||||
|
||||
- init: `sub_10001A40`;
|
||||
- update: `sub_10001230`;
|
||||
- emit: `sub_10001300`;
|
||||
- element accessor: `sub_10002830`.
|
||||
|
||||
Наблюдение по данным:
|
||||
|
||||
- `mode` (`+4`) встречается как `16` или `32`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Runtime-специфика по opcode (важные отличия)
|
||||
|
||||
### 9.1. Opcode 1
|
||||
|
||||
- создаёт handle через manager (`vfunc +48`);
|
||||
- задаёт флаги handle (`vfunc +52`);
|
||||
- в update пушит:
|
||||
- позиционный вектор 1 (`vfunc +32`),
|
||||
- позиционный вектор 2 (`vfunc +36`),
|
||||
- 4-компонентный параметр (`vfunc +12`),
|
||||
- scalar+rgb (`vfunc +16`).
|
||||
|
||||
### 9.2. Opcode 2
|
||||
|
||||
- `ResourceRef64` резолвится через `sub_100065A0` (режим-зависимая загрузка, в данных обычно `sounds.lib`/`wav`);
|
||||
- использует manager-команду id `910`.
|
||||
|
||||
### 9.3. Opcode 3/4/9
|
||||
|
||||
- общий core-emitter в `sub_100106C0`;
|
||||
- opcode 4 добавляет нормализацию по `raw+200`;
|
||||
- opcode 9 добавляет переключение render-кода (`raw+200/+204`).
|
||||
|
||||
### 9.4. Opcode 5
|
||||
|
||||
- держит массив внутренних сегментов (`332` байта/элемент, ctor `sub_100099F0`);
|
||||
- context-matrix приходит через `vfunc +24` (`sub_10003070`).
|
||||
|
||||
### 9.5. Opcode 7/10
|
||||
|
||||
- общий update/render (`sub_10001230`, `sub_10001300`);
|
||||
- разные внутренние element-форматы:
|
||||
- opcode 7: `204` байта/элемент (`sub_100092D0`),
|
||||
- opcode 10: `492` байта/элемент (`sub_1000BB40`).
|
||||
|
||||
### 9.6. Opcode 8
|
||||
|
||||
- самый тяжёлый спавнер, хранит ring/slot-структуры;
|
||||
- emit фаза (`sub_10012030`) использует `mode`, `render_pow`, per-slot transforms.
|
||||
|
||||
---
|
||||
|
||||
## 10. Спецификация инструментов
|
||||
|
||||
### 10.1. Reader (strict)
|
||||
|
||||
Алгоритм:
|
||||
|
||||
1. `len(payload) >= 60`;
|
||||
2. читаем `cmd_count`;
|
||||
3. `ptr = 0x3C`;
|
||||
4. цикл `cmd_count`:
|
||||
- `ptr + 4 <= len`;
|
||||
- `opcode in 1..10`;
|
||||
- `ptr + size(opcode) <= len`;
|
||||
- `ptr += size(opcode)`;
|
||||
5. strict-tail: `ptr == len(payload)`.
|
||||
|
||||
### 10.2. Reader (engine-compatible)
|
||||
|
||||
Legacy-режим (опасный, только при необходимости byte-совместимости):
|
||||
|
||||
- без bounds-check;
|
||||
- tolerant к unknown opcode как в оригинале.
|
||||
|
||||
### 10.3. Writer (canonical)
|
||||
|
||||
1. записать `FxHeader60`;
|
||||
2. `cmd_count = commands.len()`;
|
||||
3. команды сериализуются как `cmd_word + fixed-body`;
|
||||
4. размер payload: `0x3C + sum(size(op_i))`;
|
||||
5. без хвостовых байт.
|
||||
|
||||
### 10.4. Editor (lossless)
|
||||
|
||||
Правила:
|
||||
|
||||
- все поля little-endian;
|
||||
- не менять fixed size команды;
|
||||
- не добавлять padding;
|
||||
- сохранять неизвестные биты (`cmd_word`, `header.flags`) copy-through;
|
||||
- для частично-известных полей поддерживать режим `opaque`.
|
||||
|
||||
### 10.5. IR/JSON (рекомендуемая форма)
|
||||
|
||||
```json
|
||||
{
|
||||
"header": {
|
||||
"time_mode": 1,
|
||||
"duration_sec": 2.5,
|
||||
"phase_jitter": 0.2,
|
||||
"flags": 22,
|
||||
"settings_id": 785,
|
||||
"rand_shift": [0.0, 0.0, 0.0],
|
||||
"pivot": [0.0, 0.0, 0.0],
|
||||
"scale": [1.0, 1.0, 1.0]
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"opcode": 8,
|
||||
"word_raw": 264,
|
||||
"enabled": 1,
|
||||
"fields": {
|
||||
"mode": 1065353216,
|
||||
"eval_t0": 0.0,
|
||||
"eval_t1": 1.0,
|
||||
"resource": {"archive": "material.lib", "name": "fire_smoke"}
|
||||
},
|
||||
"opaque_extra_hex": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Проверка на реальных данных
|
||||
|
||||
`testdata/nres`:
|
||||
|
||||
- FXID payload: `923`;
|
||||
- валидация parser'а: `923/923 valid`.
|
||||
|
||||
Распределение opcode:
|
||||
|
||||
- `1: 618`
|
||||
- `2: 517`
|
||||
- `3: 1545`
|
||||
- `4: 202`
|
||||
- `5: 31`
|
||||
- `6: 0` (в датасете не встречен, но поддержан)
|
||||
- `7: 1161`
|
||||
- `8: 237`
|
||||
- `9: 266`
|
||||
- `10: 160`
|
||||
|
||||
Подтверждённые `ResourceRef64` оффсеты:
|
||||
|
||||
- op2 `+84`, op3/4/9 `+136`, op5 `+48`, op7/10 `+144`, op8 `+184`.
|
||||
|
||||
Для op1 найден редкий расширенный хвост (`+160/+192`) в `effects.rlb:r_lightray_w`:
|
||||
|
||||
- `material.lib` / `light_w`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Практический чек-лист 1:1
|
||||
|
||||
Для runtime-порта:
|
||||
|
||||
- реализовать `FxHeader60` и parser `sub_10007650`;
|
||||
- реализовать opcode-классы с методами как в vtable;
|
||||
- учитывать start/stop/restart контракт manager API;
|
||||
- воспроизвести `sub_10005C60` + post-flags (`0x20`, `0x200`);
|
||||
- воспроизвести event loop `sub_10003D30(case 28)`.
|
||||
|
||||
Для toolchain:
|
||||
|
||||
- strict validator по разделу 10.1;
|
||||
- canonical writer по разделу 10.3;
|
||||
- field-aware editor + opaque fallback для неизвестных зон.
|
||||
|
||||
---
|
||||
|
||||
## 13. Что считать «полной» совместимостью
|
||||
|
||||
Практический критерий завершения:
|
||||
|
||||
1. Парсер и writer дают byte-identical round-trip для всех 923 FXID.
|
||||
2. Runtime-порт выдаёт совпадающие state transitions на одинаковом `dt/seed` (по ключевым полям instance + command state).
|
||||
3. Все opcode `1..10` поддержаны (включая `6`, даже если отсутствует в текущем датасете).
|
||||
4. `ResourceRef64` и mode-ветки (`op1`, `op2`, `op9`) совпадают с оригиналом.
|
||||
|
||||
Эта страница покрывает весь наблюдаемый контракт формата/рантайма и полную карту body-полей по всем opcode.
|
||||
|
||||
---
|
||||
|
||||
## 14. Что осталось до «абсолютных 100%»
|
||||
|
||||
Для практического 1:1 (парсер/writer/runtime на известном контенте) покрытие уже достаточно.
|
||||
Для «абсолютных 100%» на любых входах и во всех краевых режимах остаются 3 пункта:
|
||||
|
||||
1. FP-детерминизм: оригинал опирается на x87-style вычисления; SSE/fast-math могут давать расхождения в alpha/таймингах.
|
||||
2. RNG parity: используется `sub_10002220` (16-bit генератор) и глобальные seed-состояния; для bit-exact воспроизведения нужны контрольные трассы оригинала.
|
||||
3. Редкие ветки данных: в текущем датасете нет opcode `6`, и почти не встречаются хвосты op1 (`+136..223`); для исчерпывающей валидации нужны дополнительные FXID-образцы.
|
||||
|
||||
Что нужно собрать, чтобы закрыть это полностью:
|
||||
|
||||
- frame-by-frame dump из оригинального runtime (alpha, manager flags, per-command state);
|
||||
- контрольные прогоны при фиксированном `dt` и seed;
|
||||
- минимум по одному ресурсу на каждую редкую ветку (`op6`, op1-tail с ненулевыми `+136..223`).
|
||||
874
docs/specs/materials-texm.md
Normal file
874
docs/specs/materials-texm.md
Normal file
@@ -0,0 +1,874 @@
|
||||
# Materials, WEAR, MAT0 и Texm
|
||||
|
||||
Документ описывает материальную подсистему движка (World3D/Ngi32) на уровне, достаточном для:
|
||||
|
||||
- реализации runtime 1:1;
|
||||
- создания инструментов чтения/валидации;
|
||||
- создания инструментов конвертации и редактирования с lossless round-trip.
|
||||
|
||||
Источник: дизассемблированные `tmp/disassembler1/*.c` и `tmp/disassembler2/*.asm`, плюс проверка на `tmp/gamedata`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Идентификаторы и сущности
|
||||
|
||||
| Сущность | ID (LE uint32) | ASCII | Где используется |
|
||||
|---|---:|---|---|
|
||||
| Material resource | `0x3054414D` | `MAT0` | `Material.lib` |
|
||||
| Wear resource | `0x52414557` | `WEAR` | `.wea` записи в world/mission `.rlb` |
|
||||
| Texture resource | `0x6D786554` | `Texm` | `Textures.lib`, `lightmap.lib`, другие `.lib/.rlb` |
|
||||
| Atlas tail chunk | `0x65676150` | `Page` | хвост payload `Texm` |
|
||||
|
||||
Дополнительно: палитры загружаются отдельным путём (через `SetPalettesLib` + `sub_10002B40`) и не являются `Texm`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Архитектура подсистемы
|
||||
|
||||
### 2.1 Экспортируемые точки входа (World3D)
|
||||
|
||||
- `LoadMatManager`
|
||||
- `SetPalettesLib`
|
||||
- `SetTexturesLib`
|
||||
- `SetMaterialLib`
|
||||
- `SetLightMapLib`
|
||||
- `SetGameTime`
|
||||
- `UnloadAllTextures`
|
||||
|
||||
`Set*Lib` просто копируют строки путей в глобальные буферы; валидации пути нет.
|
||||
|
||||
### 2.2 Дефолтные библиотеки (из `iron3d.dll`)
|
||||
|
||||
- `Textures.lib`
|
||||
- `Material.lib`
|
||||
- `LightMap.lib`
|
||||
- `palettes.lib` (строка собирается как `'p' + "alettes.lib"`)
|
||||
|
||||
### 2.3 Ключевые runtime-хранилища
|
||||
|
||||
1. Менеджер материалов (`LoadMatManager`) — объект `0x470` байт.
|
||||
2. Кэш текстурных объектов.
|
||||
3. Кэш lightmap-объектов.
|
||||
4. Банк загруженных палитр.
|
||||
5. Глобальный пул определений материалов (`MAT0`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout `MatManager` (0x470)
|
||||
|
||||
Объект содержит 70 таблиц wear/lightmaps (не 140).
|
||||
|
||||
```c
|
||||
// int-индексы относительно this (DWORD*), размер 284 DWORD = 0x470
|
||||
// [0] vtable
|
||||
// [1] callback iface
|
||||
// [2] callback data
|
||||
// [3..72] wearTablePtrs[70] // ptr на массив по 8 байт
|
||||
// [73..142] wearCounts[70]
|
||||
// [143] tableCount
|
||||
// [144..213] lightmapTablePtrs[70] // ptr на массив по 4 байта
|
||||
// [214..283] lightmapCounts[70]
|
||||
```
|
||||
|
||||
### 3.1 Vtable методов (`off_100209E4`)
|
||||
|
||||
| Индекс | Функция | Назначение |
|
||||
|---:|---|---|
|
||||
| 0 | `loc_10002CE0` | служебный/RTTI-заглушка |
|
||||
| 1 | `sub_10002D10` | деструктор + освобождение таблиц |
|
||||
| 2 | `PreLoadAllTextures` | экспорт, но фактически `retn 4` (заглушка) |
|
||||
| 3 | `sub_100031F0` | получить материал-фазу по `gameTime` |
|
||||
| 4 | `sub_10003AE0` | сбросить startTime записи wear к `SetGameTime()` |
|
||||
| 5 | `sub_10003680` | получить материал-фазу по нормализованному `t` |
|
||||
| 6 | `sub_10003B10` | загрузить wear/lightmaps (файл/ресурс) |
|
||||
| 7 | `sub_10003F80` | загрузить wear/lightmaps из буфера |
|
||||
| 8 | `sub_100031A0` | получить указатель на lightmap texture object |
|
||||
| 9 | `sub_10003AB0` | получить runtime-метаданные материала |
|
||||
| 10 | `sub_100031D0` | получить `wearCount` для таблицы |
|
||||
|
||||
### 3.2 Кодирование material-handle
|
||||
|
||||
`uint32 handle = (tableIndex << 16) | wearIndex`.
|
||||
|
||||
- `HIWORD(handle)` -> индекс таблицы `0..69`
|
||||
- `LOWORD(handle)` -> индекс материала в wear-таблице
|
||||
|
||||
---
|
||||
|
||||
## 4. Глобальные кэши и их ёмкость
|
||||
|
||||
Ёмкости подтверждены границами циклов/адресов в дизассемблере.
|
||||
|
||||
### 4.1 Кэш текстур (`dword_1014E910`...)
|
||||
|
||||
- Размер слота: `5 DWORD` (20 байт)
|
||||
- Ёмкость: `777`
|
||||
|
||||
```c
|
||||
struct TextureSlot {
|
||||
int32_t resIndex; // +0 индекс записи в NRes (не hash), -1 = свободно
|
||||
void* textureObject; // +4
|
||||
int32_t refCount; // +8
|
||||
uint32_t lastZeroRefTime;// +12 время, когда refCount стал 0
|
||||
uint32_t loadFlags; // +16 флаги загрузки
|
||||
};
|
||||
```
|
||||
|
||||
`lastZeroRefTime` реально используется: texture-слоты с `refCount==0` освобождаются отложенно периодическим GC.
|
||||
|
||||
### 4.2 Кэш lightmaps (`dword_10029C98`...)
|
||||
|
||||
- Тот же layout `5 DWORD`
|
||||
- Ёмкость: `100`
|
||||
|
||||
Для lightmap-слотов аналогичного периодического GC по `lastZeroRefTime` в `World3D` не наблюдается.
|
||||
|
||||
### 4.3 Пул материалов (`dword_100669F0`...)
|
||||
|
||||
- Шаг: `92 DWORD` (`368` байт)
|
||||
- Ёмкость: `700`
|
||||
|
||||
Фиксированные поля на шаг `i*92`:
|
||||
|
||||
| DWORD offset | Byte offset | Поле |
|
||||
|---:|---:|---|
|
||||
| 0 | 0 | `nameResIndex` (`MAT0` entry index), `-1` = free |
|
||||
| 1 | 4 | `refCount` |
|
||||
| 2 | 8 | `phaseCount` |
|
||||
| 3 | 12 | `phaseArrayPtr` (`phaseCount * 76`) |
|
||||
| 4 | 16 | `animBlockCount` (`< 20`) |
|
||||
| 5..84 | 20..339 | `animBlocks[20]` по 16 байт |
|
||||
| 85 | 340 | metaA (`dword_10066B44`) |
|
||||
| 86 | 344 | metaB (`dword_10066B48`) |
|
||||
| 87 | 348 | metaC (`dword_10066B4C`) |
|
||||
| 88 | 352 | metaD (`dword_10066B50`) |
|
||||
| 89 | 356 | flagA (`dword_10066B54`) |
|
||||
| 90 | 360 | nibbleMode (`dword_10066B58`) |
|
||||
| 91 | 364 | flagB (`dword_10066B5C`) |
|
||||
|
||||
### 4.4 Банк палитр
|
||||
|
||||
- `dword_1013DA58[]`
|
||||
- Загружается до `286` элементов (26 букв * 11 вариантов)
|
||||
|
||||
---
|
||||
|
||||
## 5. Загрузка палитр (`sub_10002B40`)
|
||||
|
||||
### 5.1 Генерация имён
|
||||
|
||||
Движок перебирает:
|
||||
|
||||
- буквы `'A'..'Z'`
|
||||
- суффиксы: `""`, `"0"`, `"1"`, ..., `"9"`
|
||||
|
||||
И формирует имя:
|
||||
|
||||
- `<Letter><Suffix>.PAL`
|
||||
- примеры: `A.PAL`, `A0.PAL`, ..., `Z9.PAL`
|
||||
|
||||
### 5.2 Индекс палитры
|
||||
|
||||
`paletteIndex = letterIndex * 11 + variantIndex`
|
||||
|
||||
- `letterIndex = 0..25`
|
||||
- `variantIndex = 0..10` (`""`=0, `"0"`=1, ..., `"9"`=10)
|
||||
|
||||
### 5.3 Поведение
|
||||
|
||||
- Если запись не найдена: `paletteSlots[idx] = 0`
|
||||
- Если найдена: payload отдаётся в рендер (`render->method+60`)
|
||||
|
||||
---
|
||||
|
||||
## 6. Формат `MAT0` (`Material.lib`)
|
||||
|
||||
### 6.1 Атрибуты NRes entry
|
||||
|
||||
`sub_10004310` использует:
|
||||
|
||||
- `entry.type` = `MAT0`
|
||||
- `entry.attr1` (bitfield runtime-флагов)
|
||||
- `entry.attr2` (версия/вариант заголовка payload)
|
||||
- `entry.attr3` не используется в runtime-парсере
|
||||
|
||||
Маппинг `attr1`:
|
||||
|
||||
- bit0 (`0x01`) -> добавить флаг `0x200000` в загрузку текстур фазы
|
||||
- bit1 (`0x02`) -> `flagA=1`; при некоторых HW-условиях дополнительно OR `0x80000`
|
||||
- bits2..5 -> `nibbleMode = (attr1 >> 2) & 0xF`
|
||||
- bit6 (`0x40`) -> `flagB=1`
|
||||
|
||||
### 6.2 Payload layout
|
||||
|
||||
```c
|
||||
struct Mat0Payload {
|
||||
uint16_t phaseCount;
|
||||
uint16_t animBlockCount; // должно быть < 20, иначе "Too many animations for material."
|
||||
|
||||
// Если attr2 >= 2:
|
||||
uint8_t metaA8;
|
||||
uint8_t metaB8;
|
||||
// Если attr2 >= 3:
|
||||
uint32_t metaC32;
|
||||
// Если attr2 >= 4:
|
||||
uint32_t metaD32;
|
||||
|
||||
PhaseRecordByte34 phases[phaseCount];
|
||||
AnimBlockRaw anim[animBlockCount];
|
||||
};
|
||||
```
|
||||
|
||||
Если `attr2 < 2`, runtime-значения по умолчанию:
|
||||
|
||||
- `metaA = 255`
|
||||
- `metaB = 255`
|
||||
- `metaC = 1.0f` (`0x3F800000`)
|
||||
- `metaD = 0`
|
||||
|
||||
### 6.3 `PhaseRecordByte34` -> runtime `76 bytes`
|
||||
|
||||
Сырые 34 байта:
|
||||
|
||||
```c
|
||||
struct PhaseRecordByte34 {
|
||||
uint8_t p[18]; // параметры
|
||||
char textureName[16];// если textureName[0]==0, текстуры нет
|
||||
};
|
||||
```
|
||||
|
||||
Преобразование в runtime-структуру (точный порядок):
|
||||
|
||||
| Из `p[i]` | В offset runtime | Преобразование |
|
||||
|---:|---:|---|
|
||||
| `p[0]` | `+16` | `p[0] / 255.0f` |
|
||||
| `p[1]` | `+20` | `p[1] / 255.0f` |
|
||||
| `p[2]` | `+24` | `p[2] / 255.0f` |
|
||||
| `p[3]` | `+28` | `p[3] * 0.01f` |
|
||||
| `p[4]` | `+0` | `p[4] / 255.0f` |
|
||||
| `p[5]` | `+4` | `p[5] / 255.0f` |
|
||||
| `p[6]` | `+8` | `p[6] / 255.0f` |
|
||||
| `p[7]` | `+12` | `p[7] / 255.0f` |
|
||||
| `p[8]` | `+32` | `p[8] / 255.0f` |
|
||||
| `p[9]` | `+36` | `p[9] / 255.0f` |
|
||||
| `p[10]` | `+40` | `p[10] / 255.0f` |
|
||||
| `p[11]` | `+44` | `p[11] / 255.0f` |
|
||||
| `p[12]` | `+48` | `p[12] / 255.0f` |
|
||||
| `p[13]` | `+52` | `p[13] / 255.0f` |
|
||||
| `p[14]` | `+56` | `p[14] / 255.0f` |
|
||||
| `p[15]` | `+60` | `p[15] / 255.0f` |
|
||||
| `p[16]` | `+64` | `uint32 = p[16]` |
|
||||
| `p[17]` | `+72` | `int32 = p[17]` |
|
||||
|
||||
Текстура:
|
||||
|
||||
- `textureName[0] == 0` -> `runtime[+68] = -1` и `runtime[+72] = -1`
|
||||
- иначе `runtime[+68] = LoadTexture(textureName, flags)`
|
||||
|
||||
### 6.4 Runtime-запись фазы (76 байт)
|
||||
|
||||
```c
|
||||
struct MaterialPhase76 {
|
||||
float f0; // +0
|
||||
float f1; // +4
|
||||
float f2; // +8
|
||||
float f3; // +12
|
||||
float f4; // +16
|
||||
float f5; // +20
|
||||
float f6; // +24
|
||||
float f7; // +28
|
||||
float f8; // +32
|
||||
float f9; // +36
|
||||
float f10; // +40
|
||||
float f11; // +44
|
||||
float f12; // +48
|
||||
float f13; // +52
|
||||
float f14; // +56
|
||||
float f15; // +60
|
||||
uint32_t u16; // +64
|
||||
int32_t texSlot; // +68 (индекс в texture cache, либо -1)
|
||||
int32_t i18; // +72
|
||||
};
|
||||
```
|
||||
|
||||
### 6.5 Анимационные блоки (`animBlockCount`, максимум 19)
|
||||
|
||||
Каждый блок в payload:
|
||||
|
||||
```c
|
||||
struct AnimBlockRaw {
|
||||
uint32_t headerRaw; // mode = headerRaw & 7; interpMask = headerRaw >> 3
|
||||
uint16_t keyCount;
|
||||
struct KeyRaw {
|
||||
uint16_t k0;
|
||||
uint16_t k1;
|
||||
uint16_t k2;
|
||||
} keys[keyCount];
|
||||
};
|
||||
```
|
||||
|
||||
Runtime-представление блока = 16 байт:
|
||||
|
||||
```c
|
||||
struct AnimBlockRuntime {
|
||||
uint32_t mode; // headerRaw & 7
|
||||
uint32_t interpMask;// headerRaw >> 3
|
||||
int32_t keyCount;
|
||||
void* keysPtr; // массив keyCount * 8
|
||||
};
|
||||
```
|
||||
|
||||
Ключи в runtime занимают 8 байт/ключ (с расширением `k0` до `uint32`).
|
||||
|
||||
`k2` в `sub_100031F0/sub_10003680` не используется.
|
||||
Поле нужно сохранять lossless, т.к. оно присутствует в бинарном формате.
|
||||
|
||||
### 6.6 Поиск и fallback
|
||||
|
||||
При `LoadMaterial(name)`:
|
||||
|
||||
- сначала точный поиск в `Material.lib`;
|
||||
- при промахе лог: `"Material %s not found."`;
|
||||
- fallback на `DEFAULT`;
|
||||
- если и `DEFAULT` не найден, берётся индекс `0`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Выбор текущей material-фазы
|
||||
|
||||
### 7.1 Интерполяция (`sub_10003030`)
|
||||
|
||||
Интерполируются только следующие поля (по `interpMask`):
|
||||
|
||||
- bit `0x02`: `+4,+8,+12`
|
||||
- bit `0x01`: `+20,+24,+28`
|
||||
- bit `0x04`: `+36,+40,+44`
|
||||
- bit `0x08`: `+52,+56,+60`
|
||||
- bit `0x10`: `+32`
|
||||
|
||||
Не интерполируются и копируются из «текущей» фазы:
|
||||
|
||||
- `+0,+16,+48,+64,+68,+72`
|
||||
|
||||
### 7.2 Выбор по времени (`sub_100031F0`)
|
||||
|
||||
Вход:
|
||||
|
||||
- `handle` (`tableIndex|wearIndex`)
|
||||
- `animBlockIndex`
|
||||
- глобальное время `SetGameTime()` (`dword_10032A38`)
|
||||
|
||||
Для каждой wear-записи хранится `startTime` (второй DWORD пары `8-byte`).
|
||||
|
||||
Режимы `mode = headerRaw & 7`:
|
||||
|
||||
- `0`: loop
|
||||
- `1`: ping-pong
|
||||
- `2`: one-shot clamp
|
||||
- `3`: random (`rand() % cycleLength`)
|
||||
|
||||
Важные детали 1:1:
|
||||
|
||||
- деление/остаток по циклу реализованы через unsigned `div` (`edx=0` перед делением);
|
||||
- в `mode=3` вычисленное `rand() % cycleLength` записывается прямо в `startTime` записи (не в локальную переменную).
|
||||
- при `gameTime < startTime` применяется unsigned-wrap семантика (важно для точного воспроизведения edge-case).
|
||||
|
||||
После выбора сегмента интерполяции `sub_10003030` строит scratch-материал (`unk_1013B300`), который возвращается через out-параметр.
|
||||
|
||||
### 7.3 Выбор по нормализованному `t` (`sub_10003680`)
|
||||
|
||||
Аналогично `sub_100031F0`, но time берётся как `t * cycleLength`.
|
||||
|
||||
Перед вычислением времени применяется runtime-нормализация:
|
||||
|
||||
- если `t < 0.0` или `t > 1.0`, используется `t = 0.5`.
|
||||
|
||||
### 7.4 Сброс времени записи
|
||||
|
||||
`sub_10003AE0` обновляет `startTime` конкретной wear-записи значением текущего `SetGameTime()`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Формат `WEAR` (текст)
|
||||
|
||||
`WEAR` хранится как текст в NRes entry типа `WEAR` (`0x52414557`), обычно имя `*.wea`.
|
||||
|
||||
### 8.1 Грамматика
|
||||
|
||||
```text
|
||||
<wearCount:int>\n
|
||||
<legacyId:int> <materialName>\n // повторить wearCount раз
|
||||
|
||||
[\n] // для buffer-парсера с LIGHTMAPS фактически обязательна пустая строка
|
||||
[LIGHTMAPS\n
|
||||
<lightmapCount:int>\n
|
||||
<legacyId:int> <lightmapName>\n // повторить lightmapCount раз]
|
||||
```
|
||||
|
||||
- `<legacyId>` читается, но как ключ не используется.
|
||||
- Идентификатором реально является имя (`materialName` / `lightmapName`).
|
||||
|
||||
### 8.2 Парсеры
|
||||
|
||||
1. `sub_10003B10`: файл/ресурсный режим.
|
||||
2. `sub_10003F80`: парсер из строкового буфера.
|
||||
|
||||
Различие важно для совместимости:
|
||||
|
||||
- `sub_10003B10` после `LIGHTMAPS` сразу читает `lightmapCount` через `fscanf`.
|
||||
- `sub_10003F80` после детекта `LIGHTMAPS` делает два последовательных skip до `\n`; поэтому при наличии блока `LIGHTMAPS` нужен пустой разделитель перед строкой `LIGHTMAPS`, иначе парсинг может съехать.
|
||||
|
||||
### 8.3 Поведение и ошибки
|
||||
|
||||
- `wearCount <= 0` (в текстовом файловом режиме) -> `"Illegal wear length."`
|
||||
- при невозможности открыть wear-файл/entry -> `"Wear <%s> doesn't exist."`
|
||||
- если найден блок `LIGHTMAPS` и `lightmapCount <= 0` -> `"Illegal lightmaps length."`
|
||||
- отсутствующий материал -> `"Material %s not found."` + fallback `DEFAULT`
|
||||
- отсутствующая lightmap -> `"LightMap %s not found."` и slot `-1`
|
||||
- в buffer-режиме неверная структура вокруг `LIGHTMAPS` может дать некорректный `lightmapCount` и каскадные ошибки чтения.
|
||||
|
||||
### 8.4 Ограничения runtime
|
||||
|
||||
- Таблиц в `MatManager`: максимум 70 (физический layout).
|
||||
- Жёсткой проверки на overflow таблиц в `sub_10003B10/sub_10003F80` нет.
|
||||
|
||||
Инструментам нужно явно валидировать `tableCount < 70`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Загрузка texture/lightmap по имени
|
||||
|
||||
Общие функции:
|
||||
|
||||
- `sub_10004B10` — texture (`Textures.lib`)
|
||||
- `sub_10004CB0` — lightmap (`LightMap.lib`)
|
||||
|
||||
### 9.1 Валидация имени
|
||||
|
||||
Алгоритм требует наличие `'.'` в позиции `0..16`.
|
||||
|
||||
Иначе:
|
||||
|
||||
- `"Bad texture name."`
|
||||
- возврат `-1`
|
||||
|
||||
### 9.2 Palette index из суффикса
|
||||
|
||||
После точки разбирается:
|
||||
|
||||
- `L = toupper(name[dot+1])`
|
||||
- `D = name[dot+2]` (опционально)
|
||||
- `idx = (L - 'A') * 11 + (D ? (D - '0' + 1) : 0)`
|
||||
|
||||
Если `idx < 0`, палитра не подставляется (`0`).
|
||||
Верхняя граница `idx` в runtime не проверяется.
|
||||
|
||||
Практически в стоковых ассетах имена часто вида `NAME.0`; это даёт `idx < 0`, т.е. без палитровой привязки.
|
||||
Для невалидных суффиксов это потенциально даёт OOB-чтение палитрового массива.
|
||||
|
||||
### 9.3 Кэширование
|
||||
|
||||
- Дедупликация по `resIndex`.
|
||||
- При повторном запросе увеличивается `refCount`, `lastZeroRefTime` сбрасывается в `0`.
|
||||
- При освобождении материала `refCount` texture/lightmap уменьшается.
|
||||
- texture: при `refCount -> 0` запоминается `lastZeroRefTime`; периодический sweep (примерно раз в 20 секунд) удаляет слот, если прошло больше `~60` секунд.
|
||||
- lightmap: явного аналогичного sweep-пути нет; освобождение в основном происходит при teardown таблиц (`MatManager` dtor).
|
||||
|
||||
---
|
||||
|
||||
## 10. Формат `Texm`
|
||||
|
||||
### 10.1 Заголовок 32 байта
|
||||
|
||||
```c
|
||||
struct TexmHeader32 {
|
||||
uint32_t magic; // 'Texm' = 0x6D786554
|
||||
uint32_t width;
|
||||
uint32_t height;
|
||||
uint32_t mipCount;
|
||||
uint32_t flags4;
|
||||
uint32_t flags5;
|
||||
uint32_t unk6;
|
||||
uint32_t format;
|
||||
};
|
||||
```
|
||||
|
||||
### 10.2 Поддерживаемые `format`
|
||||
|
||||
Подтверждённые в данных:
|
||||
|
||||
- `0` (палитровый 8-bit)
|
||||
- `565`
|
||||
- `4444`
|
||||
- `888`
|
||||
- `8888`
|
||||
|
||||
Поддерживается loader-ветками Ngi32 (может встречаться в runtime-генерации):
|
||||
|
||||
- `556`
|
||||
- `88`
|
||||
|
||||
### 10.3 Layout payload
|
||||
|
||||
1. `TexmHeader32`
|
||||
2. если `format == 0`: palette table `256 * 4 = 1024` байта
|
||||
3. mip-chain пикселей
|
||||
4. опциональный `Page` chunk
|
||||
|
||||
Расчёт:
|
||||
|
||||
```c
|
||||
bytesPerPixel =
|
||||
(format == 0) ? 1 :
|
||||
(format == 565 || format == 556 || format == 4444 || format == 88) ? 2 :
|
||||
4;
|
||||
|
||||
pixelCount = sum_{i=0..mipCount-1}(max(1, width>>i) * max(1, height>>i));
|
||||
sizeCore = 32 + (format == 0 ? 1024 : 0) + bytesPerPixel * pixelCount;
|
||||
```
|
||||
|
||||
### 10.4 `Page` chunk
|
||||
|
||||
```c
|
||||
struct PageChunk {
|
||||
uint32_t magic; // 'Page'
|
||||
uint32_t rectCount;
|
||||
struct Rect16 {
|
||||
int16_t x;
|
||||
int16_t w;
|
||||
int16_t y;
|
||||
int16_t h;
|
||||
} rects[rectCount];
|
||||
};
|
||||
```
|
||||
|
||||
Runtime конвертирует `Rect16` в:
|
||||
|
||||
- пиксельные прямоугольники;
|
||||
- UV-границы с учётом возможного `mipSkip`.
|
||||
|
||||
Формулы (`s = mipSkip`):
|
||||
|
||||
- `x0 = x << s`, `x1 = (x + w) << s`
|
||||
- `y0 = y << s`, `y1 = (y + h) << s`
|
||||
- `u0 = x / (width << s)`, `du = w / (width << s)`
|
||||
- `v0 = y / (height << s)`, `dv = h / (height << s)`
|
||||
|
||||
Также всегда добавляется базовый rect `[0]` на всю текстуру: пиксели `(0,0,width,height)`, UV `(0,0,1,1)`.
|
||||
|
||||
### 10.5 Loader-поведение (`sub_1000FB30`)
|
||||
|
||||
- Читает header в внутренние поля (`+56..+84`) напрямую:
|
||||
- `+56 magic`, `+60 width`, `+64 height`, `+68 mipCount`,
|
||||
- `+72 flags4`, `+76 flags5`, `+80 unk6`, `+84 format`.
|
||||
- Для `format==0` считывает palette и переставляет каналы в runtime-таблицу.
|
||||
- Считает `sizeCore`, находит tail.
|
||||
- `Page` разбирается только если включён флаг загрузки `0x400000` и tail содержит `Page`.
|
||||
- Может уменьшать стартовый mip (`sub_1000F580`) в зависимости от размеров/формата/флагов.
|
||||
- При `DisableMipmap == 0` и допустимых условиях может строить mips в runtime.
|
||||
|
||||
### 10.6 Политика `mipSkip` (`sub_1000F580`)
|
||||
|
||||
`mipSkip` зависит от `flags5 & 0x72000000`, `width`, `height`, `mipCount`:
|
||||
|
||||
- если `mipCount <= 1` -> `0`
|
||||
- если `flags5Mask == 0x02000000` -> `2` при `mipCount > 2`, иначе `1`
|
||||
- если `flags5Mask == 0x10000000` -> `1`
|
||||
- если `flags5Mask == 0x20000000`:
|
||||
- `1`, если `width >= 256` или `height >= 256`
|
||||
- иначе `0`
|
||||
- если `flags5Mask == 0x40000000`:
|
||||
- если `width > 128` и `height > 128`: `2` при `mipCount > 2`, иначе `1`
|
||||
- если `width == 128` или `height == 128`: `1`
|
||||
- иначе `0`
|
||||
- иначе `0`
|
||||
|
||||
Применение в loader:
|
||||
|
||||
- `mipCount -= mipSkip`
|
||||
- `width >>= mipSkip`, `height >>= mipSkip`
|
||||
- `pixelDataOffset += bytesPerPixel * origWidth * origHeight` для `mipSkip==1`
|
||||
- `pixelDataOffset += bytesPerPixel * origWidth * origHeight * 1.25` для `mipSkip==2` (первые два уровня)
|
||||
|
||||
---
|
||||
|
||||
## 11. Флаги профиля/рендера (Ngi32)
|
||||
|
||||
Ключ реестра: `HKCU\Software\Nikita\NgiTool`.
|
||||
|
||||
Подтверждённые значения:
|
||||
|
||||
- `Disable MultiTexturing`
|
||||
- `DisableMipmap`
|
||||
- `Force 16-bit textures`
|
||||
- `UseFirstCard`
|
||||
- `DisableD3DCalls`
|
||||
- `DisableDSound`
|
||||
- `ForceCpu`
|
||||
|
||||
Они напрямую влияют на выбор texture format path, mip handling и fallback-ветки.
|
||||
|
||||
---
|
||||
|
||||
## 12. Спецификация для toolchain (read/edit/write)
|
||||
|
||||
### 12.1 Каноническая модель данных
|
||||
|
||||
1. `MAT0`:
|
||||
- хранить исходные `attr1/attr2/attr3`;
|
||||
- хранить сырой payload + декодированную структуру;
|
||||
- при записи сохранять порядок/размеры секций точно.
|
||||
|
||||
2. `WEAR`:
|
||||
- хранить строки wear/lightmaps как текст;
|
||||
- сохранять порядок строк;
|
||||
- допускать отсутствие блока `LIGHTMAPS`.
|
||||
- если нужен полный runtime-parity с buffer-парсером (`sub_10003F80`) и есть `LIGHTMAPS`, сохранять пустую строку-разделитель перед строкой `LIGHTMAPS`.
|
||||
|
||||
3. `Texm`:
|
||||
- хранить header поля как есть (`flags4/flags5/unk6` не нормализовать);
|
||||
- хранить palette (если есть), mip data, `Page`.
|
||||
|
||||
### 12.2 Правила lossless записи
|
||||
|
||||
- Не менять значения `flags4/flags5/unk6` без явной причины.
|
||||
- Не менять `NRes` entry attrs, если цель — бинарный round-trip.
|
||||
- Для `MAT0`:
|
||||
- `animBlockCount < 20`.
|
||||
- `phaseCount` и фактический размер секции должны совпадать.
|
||||
- textureName в фазе всегда укладывать в 16 байт и NUL-терминировать.
|
||||
- Для `Texm`:
|
||||
- `magic == 'Texm'`.
|
||||
- `mipCount > 0`, `width>0`, `height>0`.
|
||||
- tail либо отсутствует, либо ровно один корректный `Page` chunk без лишних байт.
|
||||
- при эмуляции runtime-загрузчика учитывать, что `Page` обрабатывается только при load-flag `0x400000`.
|
||||
|
||||
### 12.3 Рекомендованные валидации редактора
|
||||
|
||||
- `WEAR`:
|
||||
- `wearCount > 0`.
|
||||
- число строк wear соответствует `wearCount`.
|
||||
- если есть `LIGHTMAPS`, то `lightmapCount > 0` и число строк совпадает.
|
||||
- для buffer-совместимого текста с `LIGHTMAPS` проверять наличие пустой строки перед `LIGHTMAPS`.
|
||||
- `MAT0`:
|
||||
- не выходить за payload при распаковке.
|
||||
- все ссылки фаз/keys проверять на диапазоны.
|
||||
- `Texm`:
|
||||
- `sizeCore <= payload_size`.
|
||||
- проверка `Page` как `8 + rectCount*8`.
|
||||
- предупреждать/блокировать невалидные palette suffix, которые могут дать `idx >= 286` в runtime.
|
||||
|
||||
---
|
||||
|
||||
## 13. Проверка на реальных данных (`tmp/gamedata`)
|
||||
|
||||
### 13.1 `Material.lib`
|
||||
|
||||
- `905` entries, все `type=MAT0`
|
||||
- `attr2 = 6` у всех
|
||||
- `attr3 = 0` у всех
|
||||
- `phaseCount` до `29`
|
||||
- `animBlockCount` до `8` (ограничение runtime `<20` соблюдается)
|
||||
|
||||
### 13.2 `Textures.lib`
|
||||
|
||||
- `393` entries, все `type=Texm`
|
||||
- форматы: `8888(237), 888(52), 565(47), 4444(42), 0(15)`
|
||||
- `flags4`: `32(361), 0(32)`
|
||||
- `flags5`: `0(312), 0x04000000(81)`
|
||||
- `Page` chunk присутствует у `65` текстур
|
||||
|
||||
### 13.3 `lightmap.lib`
|
||||
|
||||
- `25` entries, все `Texm`
|
||||
- формат: `565`
|
||||
- `mipCount=1`
|
||||
- `flags5`: в основном `0`, встречается `0x00800000`
|
||||
|
||||
### 13.4 `WEAR`
|
||||
|
||||
- `439` entries `type=WEAR`
|
||||
- `attr1=0, attr2=0, attr3=1`
|
||||
- `21` entry содержит блок `LIGHTMAPS` (в текущем наборе везде `lightmapCount=1`)
|
||||
- для всех `21` entry с `LIGHTMAPS` присутствует пустая строка перед `LIGHTMAPS`.
|
||||
|
||||
---
|
||||
|
||||
## 14. Opaque-поля и границы знания
|
||||
|
||||
Для 1:1 runtime/toolchain достаточно фиксировать следующие поля как `opaque-but-required`:
|
||||
|
||||
- `MAT0`:
|
||||
- `k2` в `AnimBlockRaw::KeyRaw` (хранить/писать без изменений);
|
||||
- `metaA/metaB/metaC/metaD` (в `World3D` заполняются и возвращаются наружу; внутренних consumers этих мета-полей не найдено).
|
||||
- `Texm`:
|
||||
- `flags4/flags5/unk6` (часть веток разобрана, но полная доменная семантика не требуется для 1:1).
|
||||
|
||||
Это не блокирует реализацию движка/конвертеров 1:1.
|
||||
|
||||
---
|
||||
|
||||
## 15. Минимальные псевдокоды для реализации
|
||||
|
||||
### 15.1 `parse_mat0(payload, attr2)`
|
||||
|
||||
```python
|
||||
def parse_mat0(payload: bytes, attr2: int):
|
||||
cur = 0
|
||||
phase_count = u16(payload, cur); cur += 2
|
||||
anim_count = u16(payload, cur); cur += 2
|
||||
if anim_count >= 20:
|
||||
raise ValueError("Too many animations for material")
|
||||
|
||||
if attr2 < 2:
|
||||
metaA, metaB, metaC, metaD = 255, 255, 0x3F800000, 0
|
||||
else:
|
||||
metaA = u8(payload, cur); cur += 1
|
||||
metaB = u8(payload, cur); cur += 1
|
||||
metaC = u32(payload, cur) if attr2 >= 3 else 0x3F800000
|
||||
cur += 4 if attr2 >= 3 else 0
|
||||
metaD = u32(payload, cur) if attr2 >= 4 else 0
|
||||
cur += 4 if attr2 >= 4 else 0
|
||||
|
||||
phases = [payload[cur + i*34 : cur + (i+1)*34] for i in range(phase_count)]
|
||||
cur += 34 * phase_count
|
||||
|
||||
anim = []
|
||||
for _ in range(anim_count):
|
||||
raw = u32(payload, cur); cur += 4
|
||||
key_count = u16(payload, cur); cur += 2
|
||||
keys = [payload[cur + k*6 : cur + (k+1)*6] for k in range(key_count)]
|
||||
cur += 6 * key_count
|
||||
anim.append((raw, keys))
|
||||
|
||||
if cur != len(payload):
|
||||
raise ValueError("MAT0 tail bytes")
|
||||
|
||||
return phase_count, anim_count, metaA, metaB, metaC, metaD, phases, anim
|
||||
```
|
||||
|
||||
### 15.2 `parse_texm(payload)`
|
||||
|
||||
```python
|
||||
def parse_texm(payload: bytes):
|
||||
magic, w, h, mips, f4, f5, unk6, fmt = unpack_u32x8(payload, 0)
|
||||
if magic != 0x6D786554:
|
||||
raise ValueError("not Texm")
|
||||
|
||||
bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444, 88) else 4)
|
||||
pix = 0
|
||||
mw, mh = w, h
|
||||
for _ in range(mips):
|
||||
pix += mw * mh
|
||||
mw = max(1, mw >> 1)
|
||||
mh = max(1, mh >> 1)
|
||||
|
||||
core = 32 + (1024 if fmt == 0 else 0) + bpp * pix
|
||||
if core > len(payload):
|
||||
raise ValueError("truncated")
|
||||
|
||||
page = None
|
||||
if core < len(payload):
|
||||
if core + 8 > len(payload) or payload[core:core+4] != b"Page":
|
||||
raise ValueError("tail without Page")
|
||||
n = u32(payload, core + 4)
|
||||
need = 8 + n * 8
|
||||
if core + need != len(payload):
|
||||
raise ValueError("invalid Page size")
|
||||
page = [unpack_i16x4(payload, core + 8 + i*8) for i in range(n)]
|
||||
|
||||
return (w, h, mips, fmt, f4, f5, unk6, page)
|
||||
```
|
||||
|
||||
### 15.3 `mip_skip_policy(flags5, width, height, mip_count)`
|
||||
|
||||
```python
|
||||
def mip_skip_policy(flags5: int, width: int, height: int, mip_count: int) -> int:
|
||||
if mip_count <= 1:
|
||||
return 0
|
||||
|
||||
m = flags5 & 0x72000000
|
||||
if m == 0x02000000:
|
||||
return 2 if mip_count > 2 else 1
|
||||
if m == 0x10000000:
|
||||
return 1
|
||||
if m == 0x20000000:
|
||||
return 1 if (width >= 256 or height >= 256) else 0
|
||||
if m == 0x40000000:
|
||||
if width > 128 and height > 128:
|
||||
return 2 if mip_count > 2 else 1
|
||||
if width == 128 or height == 128:
|
||||
return 1
|
||||
return 0
|
||||
```
|
||||
|
||||
### 15.4 `parse_wear_buffer_compatible(text)`
|
||||
|
||||
```python
|
||||
def parse_wear_buffer_compatible(text: str):
|
||||
lines = text.splitlines()
|
||||
i = 0
|
||||
|
||||
wear_count = int(lines[i].strip()); i += 1
|
||||
if wear_count <= 0:
|
||||
raise ValueError("Illegal wear length.")
|
||||
|
||||
wear = []
|
||||
for _ in range(wear_count):
|
||||
legacy, name = lines[i].split(maxsplit=1)
|
||||
wear.append((int(legacy), name.strip()))
|
||||
i += 1
|
||||
|
||||
lightmaps = []
|
||||
tail = lines[i:] if i < len(lines) else []
|
||||
if tail and tail[0].strip() == "":
|
||||
# sub_10003F80-совместимый разделитель перед LIGHTMAPS
|
||||
i += 1
|
||||
tail = lines[i:]
|
||||
|
||||
if tail and tail[0].strip().upper() == "LIGHTMAPS":
|
||||
i += 1
|
||||
if i >= len(lines):
|
||||
raise ValueError("Illegal lightmaps length.")
|
||||
light_count = int(lines[i].strip()); i += 1
|
||||
if light_count <= 0:
|
||||
raise ValueError("Illegal lightmaps length.")
|
||||
for _ in range(light_count):
|
||||
legacy, name = lines[i].split(maxsplit=1)
|
||||
lightmaps.append((int(legacy), name.strip()))
|
||||
i += 1
|
||||
|
||||
return wear, lightmaps
|
||||
```
|
||||
|
||||
### 15.5 `select_phase_time_1to1(...)`
|
||||
|
||||
```python
|
||||
def select_phase_time_1to1(game_time: int, start_time: int, keys, mode: int):
|
||||
# keys: list[(phase_index, t_start, t_end)], t_end последнего = cycle_len
|
||||
cycle_len = keys[-1][2]
|
||||
if cycle_len <= 0:
|
||||
return 0, 0.0
|
||||
|
||||
# unsigned div/mod как в runtime
|
||||
delta = (game_time - start_time) & 0xFFFFFFFF
|
||||
q = delta // cycle_len
|
||||
r = delta % cycle_len
|
||||
|
||||
if mode == 1: # ping-pong
|
||||
if q & 1:
|
||||
r = cycle_len - r
|
||||
elif mode == 2: # one-shot
|
||||
if q > 0:
|
||||
k = len(keys) - 1
|
||||
return k, 0.0
|
||||
elif mode == 3: # random
|
||||
r = rand32() % cycle_len
|
||||
start_time = r # side effect как в sub_100031F0
|
||||
|
||||
k = find_segment(keys, r) # t_start <= r < t_end
|
||||
kn = 0 if (k + 1 == len(keys)) else (k + 1)
|
||||
t0, t1 = keys[k][1], keys[k][2]
|
||||
alpha = 0.0 if t1 == t0 else (r - t0) / float(t1 - t0)
|
||||
return (k, kn), alpha
|
||||
```
|
||||
5
docs/specs/missions.md
Normal file
5
docs/specs/missions.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Missions
|
||||
|
||||
Документ описывает формат миссий и сценариев: начальное состояние, триггеры и связь миссий с картой мира.
|
||||
|
||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `MisLoad.dll`.
|
||||
105
docs/specs/msh-animation.md
Normal file
105
docs/specs/msh-animation.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# MSH animation
|
||||
|
||||
Документ описывает анимационные ресурсы MSH: `Res8`, `Res19` и runtime-интерполяцию.
|
||||
|
||||
---
|
||||
|
||||
## 1.13. Ресурсы анимации: Res8 и Res19
|
||||
|
||||
- **Res8** — массив анимационных ключей фиксированного размера 24 байта.
|
||||
- **Res19** — `uint16` mapping‑массив «frame → keyIndex` (с per-node смещением).
|
||||
|
||||
### 1.13.1. Формат Res8 (ключ 24 байта)
|
||||
|
||||
```c
|
||||
struct AnimKey24 {
|
||||
float posX; // +0x00
|
||||
float posY; // +0x04
|
||||
float posZ; // +0x08
|
||||
float time; // +0x0C
|
||||
int16_t qx; // +0x10
|
||||
int16_t qy; // +0x12
|
||||
int16_t qz; // +0x14
|
||||
int16_t qw; // +0x16
|
||||
};
|
||||
```
|
||||
|
||||
Декодирование quaternion-компонент:
|
||||
|
||||
```c
|
||||
q = s16 * (1.0f / 32767.0f)
|
||||
```
|
||||
|
||||
### 1.13.2. Формат Res19
|
||||
|
||||
Res19 читается как непрерывный массив `uint16`:
|
||||
|
||||
```c
|
||||
uint16_t map[]; // размер = size(Res19)/2
|
||||
```
|
||||
|
||||
Per-node управление mapping'ом берётся из заголовка узла Res1:
|
||||
|
||||
- `node.hdr2` (`Res1 + 0x04`) = `mapStart` (`0xFFFF` => map отсутствует);
|
||||
- `node.hdr3` (`Res1 + 0x06`) = `fallbackKeyIndex` и одновременно верхняя граница валидного `map`‑значения.
|
||||
|
||||
### 1.13.3. Выбор ключа для времени `t` (`sub_10012880`)
|
||||
|
||||
1) Вычислить frame‑индекс:
|
||||
|
||||
```c
|
||||
frame = (int64)(t - 0.5f); // x87 FISTP-путь
|
||||
```
|
||||
|
||||
Для строгой 1:1 эмуляции используйте именно поведение x87 `FISTP` (а не «упрощённый floor»), т.к. путь в оригинале опирается на FPU rounding mode.
|
||||
|
||||
2) Проверка условий fallback:
|
||||
|
||||
- `frame >= model.animFrameCount` (`model+0x9C`, из `NResEntry(Res19).attr2`);
|
||||
- `mapStart == 0xFFFF`;
|
||||
- `map[mapStart + frame] >= fallbackKeyIndex`.
|
||||
|
||||
Если любое условие истинно:
|
||||
|
||||
```c
|
||||
keyIndex = fallbackKeyIndex;
|
||||
```
|
||||
|
||||
Иначе:
|
||||
|
||||
```c
|
||||
keyIndex = map[mapStart + frame];
|
||||
```
|
||||
|
||||
3) Сэмплирование:
|
||||
|
||||
- `k0 = Res8[keyIndex]`
|
||||
- `k1 = Res8[keyIndex + 1]` (для интерполяции сегмента)
|
||||
|
||||
Пути:
|
||||
|
||||
- если `t == k0.time` → взять `k0`;
|
||||
- если `t == k1.time` → взять `k1`;
|
||||
- иначе `alpha = (t - k0.time) / (k1.time - k0.time)`, `pos = lerp(k0.pos, k1.pos, alpha)`, rotation смешивается через fastproc‑интерполятор quaternion.
|
||||
|
||||
### 1.13.4. Межкадровое смешивание (`sub_10012560`)
|
||||
|
||||
Функция смешивает два сэмпла (например, из двух animation time-позиций) с коэффициентом `blend`:
|
||||
|
||||
1) получить два `(quat, pos)` через `sub_10012880`;
|
||||
2) выполнить shortest‑path коррекцию знака quaternion:
|
||||
|
||||
```c
|
||||
if (|q0 + q1|^2 < |q0 - q1|^2) q1 = -q1;
|
||||
```
|
||||
|
||||
3) смешать quaternion (fastproc) и построить orientation‑матрицу;
|
||||
4) translation писать отдельно как `lerp(pos0, pos1, blend)` в ячейки `m[3], m[7], m[11]`.
|
||||
|
||||
### 1.13.5. Что хранится в `Res19.attr2`
|
||||
|
||||
При загрузке `sub_10015FD0` записывает `NResEntry(Res19).attr2` в `model+0x9C`.
|
||||
Это поле используется как верхняя граница frame‑индекса в п.1.13.3.
|
||||
|
||||
---
|
||||
|
||||
492
docs/specs/msh-core.md
Normal file
492
docs/specs/msh-core.md
Normal file
@@ -0,0 +1,492 @@
|
||||
# MSH core
|
||||
|
||||
Документ описывает core-часть формата MSH: геометрию, узлы, батчи, LOD и slot-матрицу.
|
||||
|
||||
Связанный формат контейнера: [NRes / RsLi](nres.md).
|
||||
|
||||
---
|
||||
|
||||
## 1.1. Общая архитектура
|
||||
|
||||
Модель состоит из набора именованных ресурсов внутри одного NRes‑архива. Каждый ресурс идентифицируется **целочисленным типом** (`resource_type`), который передаётся API функции `niReadData` (vtable‑метод `+0x18`) через связку `niFind` (vtable‑метод `+0x0C`, `+0x20`).
|
||||
|
||||
Рендер‑модель использует **rigid‑скининг по узлам** (нет per‑vertex bone weights). Каждый batch геометрии привязан к одному узлу и рисуется с матрицей этого узла.
|
||||
|
||||
## 1.2. Общая структура файла модели
|
||||
|
||||
```
|
||||
┌────────────────────────────────────┐
|
||||
│ NRes‑заголовок (16 байт) │
|
||||
├────────────────────────────────────┤
|
||||
│ Ресурсы (произвольный порядок): │
|
||||
│ Res1 — Node table │
|
||||
│ Res2 — Model header + Slots │
|
||||
│ Res3 — Vertex positions │
|
||||
│ Res4 — Packed normals │
|
||||
│ Res5 — Packed UV0 │
|
||||
│ Res6 — Index buffer │
|
||||
│ Res7 — Triangle descriptors │
|
||||
│ Res8 — Keyframe data │
|
||||
│ Res10 — String table │
|
||||
│ Res13 — Batch table │
|
||||
│ Res19 — Animation mapping │
|
||||
│ [Res15] — UV1 / доп. поток │
|
||||
│ [Res16] — Tangent/Bitangent │
|
||||
│ [Res18] — Vertex color │
|
||||
│ [Res20] — Доп. таблица │
|
||||
├────────────────────────────────────┤
|
||||
│ NRes‑каталог │
|
||||
└────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Ресурсы в квадратных скобках — **опциональные**. Загрузчик проверяет их наличие перед чтением (`niFindRes` возвращает `−1` при отсутствии).
|
||||
|
||||
## 1.3. Порядок загрузки ресурсов (из `sub_10015FD0` в AniMesh.dll)
|
||||
|
||||
Функция `sub_10015FD0` выполняет инициализацию внутренней структуры модели размером **0xA4** (164 байта). Ниже приведён точный порядок загрузки и маппинг ресурсов на поля структуры:
|
||||
|
||||
| Шаг | Тип ресурса | Поле структуры | Описание |
|
||||
|-----|-------------|----------------|-----------------------------------------|
|
||||
| 1 | 1 | `+0x00` | Node table (Res1) |
|
||||
| 2 | 2 | `+0x04` | Model header (Res2) |
|
||||
| 3 | 3 | `+0x0C` | Vertex positions (Res3) |
|
||||
| 4 | 4 | `+0x10` | Packed normals (Res4) |
|
||||
| 5 | 5 | `+0x14` | Packed UV0 (Res5) |
|
||||
| 6 | 10 (0x0A) | `+0x20` | String table (Res10) |
|
||||
| 7 | 8 | `+0x18` | Keyframe / animation track data (Res8) |
|
||||
| 8 | 19 (0x13) | `+0x1C` | Animation mapping (Res19) |
|
||||
| 9 | 7 | `+0x24` | Triangle descriptors (Res7) |
|
||||
| 10 | 13 (0x0D) | `+0x28` | Batch table (Res13) |
|
||||
| 11 | 6 | `+0x2C` | Index buffer (Res6) |
|
||||
| 12 | 15 (0x0F) | `+0x34` | Доп. vertex stream (Res15), опционально |
|
||||
| 13 | 16 (0x10) | `+0x38` | Доп. vertex stream (Res16), опционально |
|
||||
| 14 | 18 (0x12) | `+0x64` | Vertex color (Res18), опционально |
|
||||
| 15 | 20 (0x14) | `+0x30` | Доп. таблица (Res20), опционально |
|
||||
|
||||
### Производные поля (вычисляются после загрузки)
|
||||
|
||||
| Поле | Формула | Описание |
|
||||
|---------|-------------------------|------------------------------------------------------------------------------------------------|
|
||||
| `+0x08` | `Res2_ptr + 0x8C` | Указатель на slot table (140 байт от начала Res2) |
|
||||
| `+0x3C` | `= Res3_ptr` | Копия указателя positions (stream ptr) |
|
||||
| `+0x40` | `= 0x0C` (12) | Stride позиций: `sizeof(float3)` |
|
||||
| `+0x44` | `= Res4_ptr` | Копия указателя normals (stream ptr) |
|
||||
| `+0x48` | `= 4` | Stride нормалей: 4 байта |
|
||||
| `+0x4C` | `Res16_ptr` или `0` | Stream A Res16 (tangent) |
|
||||
| `+0x50` | `= 8` если `+0x4C != 0` | Stride stream A (используется только при наличии Res16) |
|
||||
| `+0x54` | `Res16_ptr + 4` или `0` | Stream B Res16 (bitangent) |
|
||||
| `+0x58` | `= 8` если `+0x54 != 0` | Stride stream B (используется только при наличии Res16) |
|
||||
| `+0x5C` | `= Res5_ptr` | Копия указателя UV0 (stream ptr) |
|
||||
| `+0x60` | `= 4` | Stride UV0: 4 байта |
|
||||
| `+0x68` | `= 4` или `0` | Stride Res18 (если найден) |
|
||||
| `+0x8C` | `= Res15_ptr` | Копия указателя Res15 |
|
||||
| `+0x90` | `= 8` | Stride Res15: 8 байт |
|
||||
| `+0x94` | `= 0` | Зарезервировано/unk94: инициализируется нулём при загрузке; не является флагом Res18 |
|
||||
| `+0x9C` | NRes entry Res19 `+8` | Метаданные из каталожной записи Res19 |
|
||||
| `+0xA0` | NRes entry Res20 `+4` | Метаданные из каталожной записи Res20 (заполняется только если Res20 найден и открыт, иначе 0) |
|
||||
|
||||
**Примечание к метаданным:** поле `+0x9C` читается из каталожной записи NRes для ресурса 19 (смещение `+8` в записи каталога, т.е. `attribute_2`). Поле `+0xA0` — из каталожной записи для ресурса 20 (смещение `+4`, т.е. `attribute_1`) **только если Res20 найден и `niOpenRes` вернул ненулевой указатель**; иначе `+0xA0 = 0`. Индекс записи определяется как `entry_index * 64`, после чего считывается поле.
|
||||
|
||||
---
|
||||
|
||||
### 1.3.1. Ссылки на функции и паттерны вызовов (для проверки реверса)
|
||||
|
||||
- `AniMesh.dll!sub_10015FD0` — загрузка ресурсов модели через vtable интерфейса NRes:
|
||||
- `niFindRes(type, ...)` вызывается через `call [vtable+0x20]`
|
||||
- `niOpenRes(...)` / чтение указателя — через `call [vtable+0x18]`
|
||||
- `AniMesh.dll!sub_10015FD0` выставляет производные поля (`Res2_ptr+0x8C`, stride'ы), обнуляет `model+0x94`, и при отсутствии Res16 обнуляет только указатели потоков (`+0x4C`, `+0x54`).
|
||||
- `AniMesh.dll!sub_10004840` / `sub_10004870` / `sub_100048A0` — использование runtime mapping‑таблицы (`+0x18`, индекс `boneId*4`) и таблицы указателей треков (`+0x08`) после построения анимационного объекта.
|
||||
|
||||
|
||||
## 1.4. Ресурс Res2 — Model Header (140 байт) + Slot Table
|
||||
|
||||
Ресурс Res2 содержит:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────┐ Смещение 0
|
||||
│ Model Header (140 байт = 0x8C) │
|
||||
├───────────────────────────────────┤ Смещение 140 (0x8C)
|
||||
│ Slot Table │
|
||||
│ (slot_count × 68 байт) │
|
||||
└───────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.4.1. Model Header (первые 140 байт)
|
||||
|
||||
Поле `Res2[0x00..0x8B]` используется как **35 float** (без внутренних таблиц/индексов). Это подтверждено прямыми копированиями в `AniMesh.dll!sub_1000A460`:
|
||||
|
||||
- `qmemcpy(this+0x54, Res2+0x00, 0x60)` — первые 24 float;
|
||||
- копирование `Res2+0x60` размером `0x10` — ещё 4 float;
|
||||
- `qmemcpy(this+0x134, Res2+0x70, 0x1C)` — ещё 7 float.
|
||||
|
||||
Итоговая раскладка:
|
||||
|
||||
| Диапазон | Размер | Тип | Семантика |
|
||||
|--------------|--------|-------------|----------------------------------------------------------------------|
|
||||
| `0x00..0x5F` | `0x60` | `float[24]` | 8 вершин глобального bounding‑hull (`vec3[8]`) |
|
||||
| `0x60..0x6F` | `0x10` | `float[4]` | Глобальная bounding‑sphere: `center.xyz + radius` |
|
||||
| `0x70..0x8B` | `0x1C` | `float[7]` | Глобальный «капсульный»/сегментный bound: `A.xyz`, `B.xyz`, `radius` |
|
||||
|
||||
Для рендера и broadphase движок использует как слот‑bounds (`Res2 slot`), так и этот глобальный набор bounds (в зависимости от контекста вызова/LOD и наличия слота).
|
||||
|
||||
### 1.4.2. Slot Table (массив записей по 68 байт)
|
||||
|
||||
Slot — ключевая структура, связывающая узел иерархии с конкретной геометрией для конкретного LOD и группы. Каждая запись — **68 байт** (0x44).
|
||||
|
||||
**Важно:** смещения в таблице ниже указаны в **десятичном формате** (байты). В скобках приведён hex‑эквивалент (например, 48 (0x30)).
|
||||
|
||||
|
||||
| Смещение | Размер | Тип | Описание |
|
||||
|-----------|--------|----------|-----------------------------------------------------|
|
||||
| 0 | 2 | uint16 | `triStart` — индекс первого треугольника в Res7 |
|
||||
| 2 | 2 | uint16 | `triCount` — длина диапазона треугольников (`Res7`) |
|
||||
| 4 | 2 | uint16 | `batchStart` — индекс первого batch'а в Res13 |
|
||||
| 6 | 2 | uint16 | `batchCount` — количество batch'ей |
|
||||
| 8 | 4 | float | `aabbMin.x` |
|
||||
| 12 | 4 | float | `aabbMin.y` |
|
||||
| 16 | 4 | float | `aabbMin.z` |
|
||||
| 20 | 4 | float | `aabbMax.x` |
|
||||
| 24 | 4 | float | `aabbMax.y` |
|
||||
| 28 | 4 | float | `aabbMax.z` |
|
||||
| 32 | 4 | float | `sphereCenter.x` |
|
||||
| 36 | 4 | float | `sphereCenter.y` |
|
||||
| 40 | 4 | float | `sphereCenter.z` |
|
||||
| 44 (0x2C) | 4 | float | `sphereRadius` |
|
||||
| 48 (0x30) | 20 | 5×uint32 | Хвостовые поля: `unk30..unk40` (см. §1.4.2.1) |
|
||||
|
||||
**AABB** — axis‑aligned bounding box в локальных координатах узла.
|
||||
**Bounding Sphere** — описанная сфера в локальных координатах узла.
|
||||
|
||||
#### 1.4.2.1. Точная семантика `triStart/triCount`
|
||||
|
||||
В `AniMesh.dll!sub_1000B2C0` слот считается «владельцем» треугольника `triId`, если:
|
||||
|
||||
```c
|
||||
triId >= slot.triStart && triId < slot.triStart + slot.triCount
|
||||
```
|
||||
|
||||
Это прямое доказательство, что `slot +0x02` — именно **count диапазона**, а не флаги.
|
||||
|
||||
#### 1.4.2.2. Хвост слота (20 байт = 5×uint32)
|
||||
|
||||
Последние 20 байт записи слота трактуем как 5 последовательных 32‑битных значений (little‑endian). Их назначение пока не подтверждено; для инструментов рекомендуется сохранять и восстанавливать их «как есть».
|
||||
|
||||
- `+48 (0x30)`: `unk30` (uint32)
|
||||
- `+52 (0x34)`: `unk34` (uint32)
|
||||
- `+56 (0x38)`: `unk38` (uint32)
|
||||
- `+60 (0x3C)`: `unk3C` (uint32)
|
||||
- `+64 (0x40)`: `unk40` (uint32)
|
||||
|
||||
Для culling при рендере: AABB/sphere трансформируются матрицей узла и инстанса. При неравномерном scale радиус сферы масштабируется по `max(scaleX, scaleY, scaleZ)` (подтверждено по коду).
|
||||
|
||||
---
|
||||
|
||||
### 1.4.3. Восстановление счётчиков элементов по размерам ресурсов (практика для инструментов)
|
||||
|
||||
Для toolchain надёжнее считать count'ы по размерам ресурсов (а не по дублирующим полям других таблиц). Это полностью совпадает с тем, как рантайм использует fixed stride'ы в `sub_10015FD0`.
|
||||
|
||||
Берите **unpacked_size** (или фактический размер распакованного блока) соответствующего ресурса и вычисляйте:
|
||||
|
||||
- `node_count` = `size(Res1) / 38`
|
||||
- `vertex_count` = `size(Res3) / 12`
|
||||
- `normals_count` = `size(Res4) / 4`
|
||||
- `uv0_count` = `size(Res5) / 4`
|
||||
- `index_count` = `size(Res6) / 2`
|
||||
- `tri_count` = `index_count / 3` (если примитивы — список треугольников)
|
||||
- `tri_desc_count` = `size(Res7) / 16`
|
||||
- `batch_count` = `size(Res13) / 20`
|
||||
- `slot_count` = `(size(Res2) - 0x8C) / 0x44`
|
||||
- `anim_key_count` = `size(Res8) / 24`
|
||||
- `anim_map_count` = `size(Res19) / 2`
|
||||
- `uv1_count` = `size(Res15) / 8` (если Res15 присутствует)
|
||||
- `tbn_count` = `size(Res16) / 8` (если Res16 присутствует; tangent/bitangent по 4 байта, stride 8)
|
||||
- `color_count` = `size(Res18) / 4` (если Res18 присутствует)
|
||||
|
||||
**Валидация:**
|
||||
|
||||
- Любое деление должно быть **без остатка**; иначе ресурс повреждён или stride неверно угадан.
|
||||
- Если присутствуют Res4/Res5/Res15/Res16/Res18, их count'ы по смыслу должны совпадать с `vertex_count` (или быть ≥ него, если формат допускает хвостовые данные — пока не наблюдалось).
|
||||
- Для `slot_count` дополнительно проверьте, что `size(Res2) >= 0x8C`.
|
||||
|
||||
**Проверка на реальных данных (435 MSH):**
|
||||
|
||||
- `Res2.attr1 == (size-140)/68`, `Res2.attr2 == 0`, `Res2.attr3 == 68`;
|
||||
- `Res7.attr1 == size/16`, `Res7.attr3 == 16`;
|
||||
- `Res8.attr1 == size/24`, `Res8.attr3 == 4`;
|
||||
- `Res19.attr1 == size/2`, `Res19.attr3 == 2`;
|
||||
- для `Res1` почти всегда `attr3 == 38` (один служебный outlier: `MTCHECK.MSH` с `attr3 == 24`).
|
||||
|
||||
Эти формулы достаточны, чтобы реализовать распаковщик/просмотрщик геометрии и батчей даже без полного понимания полей заголовка Res2.
|
||||
|
||||
## 1.5. Ресурс Res1 — Node Table (38 байт на узел)
|
||||
|
||||
Node table — компактная карта слотов по уровням LOD и группам. Каждый узел занимает **38 байт** (19 × `uint16`).
|
||||
|
||||
### Адресация слота
|
||||
|
||||
Движок вычисляет индекс слова в таблице:
|
||||
|
||||
```
|
||||
word_index = nodeIndex × 19 + lod × 5 + group + 4
|
||||
slot_index = node_table[word_index] // uint16, 0xFFFF = нет слота
|
||||
```
|
||||
|
||||
Параметры:
|
||||
|
||||
- `lod`: 0..2 (три уровня детализации). Значение `−1` → подставляется `current_lod` из инстанса.
|
||||
- `group`: 0..4 (пять групп). На практике чаще всего используется `group = 0`.
|
||||
|
||||
### Раскладка записи узла (38 байт)
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────┐
|
||||
│ Header: 4 × uint16 (8 байт) │
|
||||
│ hdr0, hdr1, hdr2, hdr3 │
|
||||
├───────────────────────────────────────────────────────┤
|
||||
│ SlotIndex matrix: 3 LOD × 5 groups = 15 × uint16 │
|
||||
│ LOD 0: group[0..4] │
|
||||
│ LOD 1: group[0..4] │
|
||||
│ LOD 2: group[0..4] │
|
||||
└───────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Смещение | Размер | Тип | Описание |
|
||||
|----------|--------|------------|-----------------------------------------|
|
||||
| 0 | 8 | uint16[4] | Заголовок узла (`hdr0..hdr3`, см. ниже) |
|
||||
| 8 | 30 | uint16[15] | Матрица слотов: `slotIndex[lod][group]` |
|
||||
|
||||
`slotIndex = 0xFFFF` означает «слот отсутствует» — узел при данном LOD и группе не рисуется.
|
||||
|
||||
Подтверждённые семантики полей `hdr*`:
|
||||
|
||||
- `hdr1` (`+0x02`) — parent/index-link при построении инстанса (в `sub_1000A460` читается как индекс связанного узла, `0xFFFF` = нет связи).
|
||||
- `hdr2` (`+0x04`) — `mapStart` для Res19 (`0xFFFF` = нет карты; fallback по `hdr3`).
|
||||
- `hdr3` (`+0x06`) — `fallbackKeyIndex`/верхняя граница для map‑значений (используется в `sub_10012880`).
|
||||
|
||||
`hdr0` (`+0x00`) по коду участвует в битовых проверках (`&0x40`, `byte+1 & 8`) и несёт флаги узла.
|
||||
|
||||
**Группы (group 0..4):** в рантайме это ортогональный индекс к LOD (матрица 5×3 на узел). Имена групп в оригинальных ресурсах не подписаны; для 1:1 нужно сохранять группы как «сырой» индекс 0..4 без переинтерпретации.
|
||||
|
||||
---
|
||||
|
||||
## 1.6. Ресурс Res3 — Vertex Positions
|
||||
|
||||
**Формат:** массив `float3` (IEEE 754 single‑precision).
|
||||
**Stride:** 12 байт.
|
||||
|
||||
```c
|
||||
struct Position {
|
||||
float x; // +0
|
||||
float y; // +4
|
||||
float z; // +8
|
||||
};
|
||||
```
|
||||
|
||||
Чтение: `pos = *(float3*)(res3_data + 12 * vertexIndex)`.
|
||||
|
||||
---
|
||||
|
||||
## 1.7. Ресурс Res4 — Packed Normals
|
||||
|
||||
**Формат:** 4 байта на вершину.
|
||||
**Stride:** 4 байта.
|
||||
|
||||
```c
|
||||
struct PackedNormal {
|
||||
int8_t nx; // +0
|
||||
int8_t ny; // +1
|
||||
int8_t nz; // +2
|
||||
int8_t nw; // +3 (назначение не подтверждено: паддинг / знак / индекс)
|
||||
};
|
||||
```
|
||||
|
||||
### Алгоритм декодирования (подтверждено по AniMesh.dll)
|
||||
|
||||
> В движке используется делитель **127.0**, а не 128.0 (см. константу `127.0` рядом с `1024.0`/`32767.0`).
|
||||
|
||||
```
|
||||
normal.x = clamp((float)nx / 127.0, -1.0, 1.0)
|
||||
normal.y = clamp((float)ny / 127.0, -1.0, 1.0)
|
||||
normal.z = clamp((float)nz / 127.0, -1.0, 1.0)
|
||||
```
|
||||
|
||||
**Множитель:** `1.0 / 127.0 ≈ 0.0078740157`.
|
||||
**Диапазон входных значений:** −128..+127 → выход ≈ −1.007874..+1.0 → **после клампа** −1.0..+1.0.
|
||||
**Почему нужен кламп:** значение `-128` при делении на `127.0` даёт модуль чуть больше 1.
|
||||
**4‑й байт (nw):** используется ли он как часть нормали, как индекс или просто как выравнивание — не подтверждено. Рекомендация: игнорировать при первичном импорте.
|
||||
|
||||
---
|
||||
|
||||
## 1.8. Ресурс Res5 — Packed UV0
|
||||
|
||||
**Формат:** 4 байта на вершину (два `int16`).
|
||||
**Stride:** 4 байта.
|
||||
|
||||
```c
|
||||
struct PackedUV {
|
||||
int16_t u; // +0
|
||||
int16_t v; // +2
|
||||
};
|
||||
```
|
||||
|
||||
### Алгоритм декодирования
|
||||
|
||||
```
|
||||
uv.u = (float)u / 1024.0
|
||||
uv.v = (float)v / 1024.0
|
||||
```
|
||||
|
||||
**Множитель:** `1.0 / 1024.0 = 0.0009765625`.
|
||||
**Диапазон входных значений:** −32768..+32767 → выход ≈ −32.0..+31.999.
|
||||
Значения >1.0 или <0.0 означают wrapping/repeat текстурных координат.
|
||||
|
||||
### Алгоритм кодирования (для экспортёра)
|
||||
|
||||
```
|
||||
packed_u = (int16_t)round(uv.u * 1024.0)
|
||||
packed_v = (int16_t)round(uv.v * 1024.0)
|
||||
```
|
||||
|
||||
Результат обрезается (clamp) до диапазона `int16` (−32768..+32767).
|
||||
|
||||
---
|
||||
|
||||
## 1.9. Ресурс Res6 — Index Buffer
|
||||
|
||||
**Формат:** массив `uint16` (беззнаковые 16‑битные индексы).
|
||||
**Stride:** 2 байта.
|
||||
|
||||
Максимальное число вершин в одном batch: 65535.
|
||||
Индексы используются совместно с `baseVertex` из batch table:
|
||||
|
||||
```
|
||||
actual_vertex_index = index_buffer[indexStart + i] + baseVertex
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1.10. Ресурс Res7 — Triangle Descriptors
|
||||
|
||||
**Формат:** массив записей по 16 байт. Одна запись на треугольник.
|
||||
|
||||
| Смещение | Размер | Тип | Описание |
|
||||
|----------|--------|----------|---------------------------------------------|
|
||||
| `+0x00` | 2 | `uint16` | `triFlags` — фильтрация/материал tri‑уровня |
|
||||
| `+0x02` | 2 | `uint16` | `linkTri0` — tri‑ref для связанного обхода |
|
||||
| `+0x04` | 2 | `uint16` | `linkTri1` — tri‑ref для связанного обхода |
|
||||
| `+0x06` | 2 | `uint16` | `linkTri2` — tri‑ref для связанного обхода |
|
||||
| `+0x08` | 2 | `int16` | `nX` (packed, scale `1/32767`) |
|
||||
| `+0x0A` | 2 | `int16` | `nY` (packed, scale `1/32767`) |
|
||||
| `+0x0C` | 2 | `int16` | `nZ` (packed, scale `1/32767`) |
|
||||
| `+0x0E` | 2 | `uint16` | `selPacked` — 3 селектора по 2 бита |
|
||||
|
||||
Расшифровка `selPacked` (`AniMesh.dll!sub_10013680`):
|
||||
|
||||
```c
|
||||
sel0 = selPacked & 0x3; if (sel0 == 3) sel0 = 0xFFFF;
|
||||
sel1 = (selPacked >> 2) & 0x3; if (sel1 == 3) sel1 = 0xFFFF;
|
||||
sel2 = (selPacked >> 4) & 0x3; if (sel2 == 3) sel2 = 0xFFFF;
|
||||
```
|
||||
|
||||
`linkTri*` передаются в `sub_1000B2C0` и используются для построения соседнего набора треугольников при коллизии/пикинге.
|
||||
|
||||
**Важно:** дескрипторы не хранят индексы вершин треугольника. Индексы берутся из Res6 (index buffer) через `indexStart`/`indexCount` соответствующего batch'а.
|
||||
|
||||
Дескрипторы используются при обходе треугольников для коллизии и пикинга. `triStart` из slot table указывает, с какого дескриптора начинать обход для данного слота.
|
||||
|
||||
---
|
||||
|
||||
## 1.11. Ресурс Res13 — Batch Table
|
||||
|
||||
**Формат:** массив записей по 20 байт. Batch — минимальная единица отрисовки.
|
||||
|
||||
| Смещение | Размер | Тип | Описание |
|
||||
|----------|--------|--------|---------------------------------------------------------|
|
||||
| 0 | 2 | uint16 | `batchFlags` — битовая маска для фильтрации |
|
||||
| 2 | 2 | uint16 | `materialIndex` — индекс материала |
|
||||
| 4 | 2 | uint16 | `unk4` — неподтверждённое поле |
|
||||
| 6 | 2 | uint16 | `unk6` — вероятный `nodeIndex` (привязка batch к кости) |
|
||||
| 8 | 2 | uint16 | `indexCount` — число индексов (кратно 3) |
|
||||
| 10 | 4 | uint32 | `indexStart` — стартовый индекс в Res6 (в элементах) |
|
||||
| 14 | 2 | uint16 | `unk14` — неподтверждённое поле |
|
||||
| 16 | 4 | uint32 | `baseVertex` — смещение вершинного индекса |
|
||||
|
||||
### Использование при рендере
|
||||
|
||||
```
|
||||
for i in 0 .. indexCount-1:
|
||||
raw_index = index_buffer[indexStart + i]
|
||||
vertex_index = raw_index + baseVertex
|
||||
position = res3[vertex_index]
|
||||
normal = decode_normal(res4[vertex_index])
|
||||
uv = decode_uv(res5[vertex_index])
|
||||
```
|
||||
|
||||
**Примечание:** движок читает `indexStart` как `uint32` и умножает на 2 для получения байтового смещения в массиве `uint16`.
|
||||
|
||||
---
|
||||
|
||||
## 1.12. Ресурс Res10 — String Table
|
||||
|
||||
Res10 — это **последовательность записей, индексируемых по `nodeIndex`** (см. `AniMesh.dll!sub_10012530`).
|
||||
|
||||
Формат одной записи:
|
||||
|
||||
```c
|
||||
struct Res10Record {
|
||||
uint32_t len; // число символов без терминирующего '\0'
|
||||
char text[]; // если len > 0: хранится len+1 байт (включая '\0')
|
||||
// если len == 0: payload отсутствует
|
||||
};
|
||||
```
|
||||
|
||||
Переход к следующей записи:
|
||||
|
||||
```c
|
||||
next = cur + 4 + (len ? (len + 1) : 0);
|
||||
```
|
||||
|
||||
`sub_10012530` возвращает:
|
||||
|
||||
- `NULL`, если `len == 0`;
|
||||
- `record + 4`, если `len > 0` (указатель на C‑строку).
|
||||
|
||||
Это значение используется в `sub_1000A460` для проверки имени текущего узла (например, поиск подстроки `"central"` при обработке node‑флагов).
|
||||
|
||||
---
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 1.14. Опциональные vertex streams
|
||||
|
||||
### Res15 — Дополнительный vertex stream (stride 8)
|
||||
|
||||
- **Stride:** 8 байт на вершину.
|
||||
- **Кандидаты:** `float2 uv1` (lightmap / second UV layer), 4 × `int16` (2 UV‑пары), либо иной формат.
|
||||
- Загружается условно — если ресурс 15 отсутствует, указатель равен `NULL`.
|
||||
|
||||
### Res16 — Tangent / Bitangent (stride 8, split 2×4)
|
||||
|
||||
- **Stride:** 8 байт на вершину (2 подпотока по 4 байта).
|
||||
- При загрузке движок создаёт **два перемежающихся (interleaved) подпотока**:
|
||||
- Stream A: `base + 0`, stride 8 — 4 байта (кандидат: packed tangent, `int8 × 4`)
|
||||
- Stream B: `base + 4`, stride 8 — 4 байта (кандидат: packed bitangent, `int8 × 4`)
|
||||
- Если ресурс 16 отсутствует, оба указателя обнуляются.
|
||||
- **Важно:** в оригинальном `sub_10015FD0` при отсутствии Res16 страйды `+0x50/+0x58` явным образом не обнуляются; это безопасно, потому что оба указателя равны `NULL` и код не должен обращаться к потокам без проверки указателя.
|
||||
- Декодирование предположительно аналогично нормалям: `component / 127.0` (как Res4), но требует подтверждения; при импорте — кламп в [-1..1].
|
||||
|
||||
### Res18 — Vertex Color (stride 4)
|
||||
|
||||
- **Stride:** 4 байта на вершину.
|
||||
- **Кандидаты:** `D3DCOLOR` (BGRA), packed параметры освещения, vertex AO.
|
||||
- Загружается условно (через проверку `niFindRes` на возврат `−1`).
|
||||
|
||||
### Res20 — Дополнительная таблица
|
||||
|
||||
- Присутствует не всегда.
|
||||
- Из каталожной записи NRes считывается поле `attribute_1` (смещение `+4`) и сохраняется как метаданные.
|
||||
- **Кандидаты:** vertex remap, дополнительные данные для эффектов/деформаций.
|
||||
|
||||
---
|
||||
|
||||
277
docs/specs/msh-notes.md
Normal file
277
docs/specs/msh-notes.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# 3D implementation notes
|
||||
|
||||
Контрольные заметки, сводки алгоритмов и остаточные семантические вопросы по 3D-подсистемам.
|
||||
|
||||
---
|
||||
|
||||
## 5.1. Порядок байт
|
||||
|
||||
Все значения хранятся в **little‑endian** порядке (платформа x86/Win32).
|
||||
|
||||
## 5.2. Выравнивание
|
||||
|
||||
- **NRes‑ресурсы:** данные каждого ресурса внутри NRes‑архива выровнены по границе **8 байт** (0‑padding).
|
||||
- **Внутренняя структура ресурсов:** таблицы Res1/Res2/Res7/Res13 не имеют межзаписевого выравнивания — записи идут подряд.
|
||||
- **Vertex streams:** stride'ы фиксированы (12/4/8 байт) — вершинные данные идут подряд без паддинга.
|
||||
|
||||
## 5.3. Размеры записей на диске
|
||||
|
||||
| Ресурс | Запись | Размер (байт) | Stride |
|
||||
|--------|-----------|---------------|-------------------------|
|
||||
| Res1 | Node | 38 | 38 (19×u16) |
|
||||
| Res2 | Slot | 68 | 68 |
|
||||
| Res3 | Position | 12 | 12 (3×f32) |
|
||||
| Res4 | Normal | 4 | 4 (4×s8) |
|
||||
| Res5 | UV0 | 4 | 4 (2×s16) |
|
||||
| Res6 | Index | 2 | 2 (u16) |
|
||||
| Res7 | TriDesc | 16 | 16 |
|
||||
| Res8 | AnimKey | 24 | 24 |
|
||||
| Res10 | StringRec | переменный | `4 + (len ? len+1 : 0)` |
|
||||
| Res13 | Batch | 20 | 20 |
|
||||
| Res19 | AnimMap | 2 | 2 (u16) |
|
||||
| Res15 | VtxStr | 8 | 8 |
|
||||
| Res16 | VtxStr | 8 | 8 (2×4) |
|
||||
| Res18 | VtxStr | 4 | 4 |
|
||||
|
||||
## 5.4. Вычисление количества элементов
|
||||
|
||||
Количество записей вычисляется из размера ресурса:
|
||||
|
||||
```
|
||||
count = resource_data_size / record_stride
|
||||
```
|
||||
|
||||
Например:
|
||||
|
||||
- `vertex_count = res3_size / 12`
|
||||
- `index_count = res6_size / 2`
|
||||
- `batch_count = res13_size / 20`
|
||||
- `slot_count = (res2_size - 140) / 68`
|
||||
- `node_count = res1_size / 38`
|
||||
- `tri_desc_count = res7_size / 16`
|
||||
- `anim_key_count = res8_size / 24`
|
||||
- `anim_map_count = res19_size / 2`
|
||||
|
||||
Для Res10 нет фиксированного stride: нужно последовательно проходить записи `u32 len` + `(len ? len+1 : 0)` байт.
|
||||
|
||||
## 5.5. Идентификация ресурсов в NRes
|
||||
|
||||
Ресурсы модели идентифицируются по полю `type` (смещение 0) в каталожной записи NRes. Загрузчик использует `niFindRes(archive, type, subtype)` для поиска, где `type` — число (1, 2, 3, ... 20), а `subtype` (byte) — уточнение (из аргумента загрузчика).
|
||||
|
||||
## 5.6. Минимальный набор для рендера
|
||||
|
||||
Для статической модели без анимации достаточно:
|
||||
|
||||
| Ресурс | Обязательность |
|
||||
|--------|------------------------------------------------|
|
||||
| Res1 | Да |
|
||||
| Res2 | Да |
|
||||
| Res3 | Да |
|
||||
| Res4 | Рекомендуется |
|
||||
| Res5 | Рекомендуется |
|
||||
| Res6 | Да |
|
||||
| Res7 | Для коллизии |
|
||||
| Res13 | Да |
|
||||
| Res10 | Желательно (узловые имена/поведенческие ветки) |
|
||||
| Res8 | Нет (анимация) |
|
||||
| Res19 | Нет (анимация) |
|
||||
| Res15 | Нет |
|
||||
| Res16 | Нет |
|
||||
| Res18 | Нет |
|
||||
| Res20 | Нет |
|
||||
|
||||
## 5.7. Сводка алгоритмов декодирования
|
||||
|
||||
### Позиции (Res3)
|
||||
|
||||
```python
|
||||
def decode_position(data, vertex_index):
|
||||
offset = vertex_index * 12
|
||||
x = struct.unpack_from('<f', data, offset)[0]
|
||||
y = struct.unpack_from('<f', data, offset + 4)[0]
|
||||
z = struct.unpack_from('<f', data, offset + 8)[0]
|
||||
return (x, y, z)
|
||||
```
|
||||
|
||||
### Нормали (Res4)
|
||||
|
||||
```python
|
||||
def decode_normal(data, vertex_index):
|
||||
offset = vertex_index * 4
|
||||
nx = struct.unpack_from('<b', data, offset)[0] # int8
|
||||
ny = struct.unpack_from('<b', data, offset + 1)[0]
|
||||
nz = struct.unpack_from('<b', data, offset + 2)[0]
|
||||
# nw = data[offset + 3] # не используется
|
||||
return (
|
||||
max(-1.0, min(1.0, nx / 127.0)),
|
||||
max(-1.0, min(1.0, ny / 127.0)),
|
||||
max(-1.0, min(1.0, nz / 127.0)),
|
||||
)
|
||||
```
|
||||
|
||||
### UV‑координаты (Res5)
|
||||
|
||||
```python
|
||||
def decode_uv(data, vertex_index):
|
||||
offset = vertex_index * 4
|
||||
u = struct.unpack_from('<h', data, offset)[0] # int16
|
||||
v = struct.unpack_from('<h', data, offset + 2)[0]
|
||||
return (u / 1024.0, v / 1024.0)
|
||||
```
|
||||
|
||||
### Кодирование нормали (для экспортёра)
|
||||
|
||||
```python
|
||||
def encode_normal(nx, ny, nz):
|
||||
return (
|
||||
max(-128, min(127, int(round(nx * 127.0)))),
|
||||
max(-128, min(127, int(round(ny * 127.0)))),
|
||||
max(-128, min(127, int(round(nz * 127.0)))),
|
||||
0 # nw = 0 (безопасное значение)
|
||||
)
|
||||
```
|
||||
|
||||
### Кодирование UV (для экспортёра)
|
||||
|
||||
```python
|
||||
def encode_uv(u, v):
|
||||
return (
|
||||
max(-32768, min(32767, int(round(u * 1024.0)))),
|
||||
max(-32768, min(32767, int(round(v * 1024.0))))
|
||||
)
|
||||
```
|
||||
|
||||
### Строки узлов (Res10)
|
||||
|
||||
```python
|
||||
def parse_res10_for_nodes(buf: bytes, node_count: int) -> list[str | None]:
|
||||
out = []
|
||||
off = 0
|
||||
for _ in range(node_count):
|
||||
ln = struct.unpack_from('<I', buf, off)[0]
|
||||
off += 4
|
||||
if ln == 0:
|
||||
out.append(None)
|
||||
continue
|
||||
raw = buf[off:off + ln + 1] # len + '\0'
|
||||
out.append(raw[:-1].decode('ascii', errors='replace'))
|
||||
off += ln + 1
|
||||
return out
|
||||
```
|
||||
|
||||
### Ключ анимации (Res8) и mapping (Res19)
|
||||
|
||||
```python
|
||||
def decode_anim_key24(buf: bytes, idx: int):
|
||||
o = idx * 24
|
||||
px, py, pz, t = struct.unpack_from('<4f', buf, o)
|
||||
qx, qy, qz, qw = struct.unpack_from('<4h', buf, o + 16)
|
||||
s = 1.0 / 32767.0
|
||||
return (px, py, pz), t, (qx * s, qy * s, qz * s, qw * s)
|
||||
```
|
||||
|
||||
### Эффектный поток (FXID)
|
||||
|
||||
```python
|
||||
FX_CMD_SIZE = {1:224,2:148,3:200,4:204,5:112,6:4,7:208,8:248,9:208,10:208}
|
||||
|
||||
def parse_fx_payload(raw: bytes):
|
||||
cmd_count = struct.unpack_from('<I', raw, 0)[0]
|
||||
ptr = 0x3C
|
||||
cmds = []
|
||||
for _ in range(cmd_count):
|
||||
w = struct.unpack_from('<I', raw, ptr)[0]
|
||||
op = w & 0xFF
|
||||
enabled = (w >> 8) & 1
|
||||
size = FX_CMD_SIZE[op]
|
||||
cmds.append((op, enabled, ptr, size))
|
||||
ptr += size
|
||||
if ptr != len(raw):
|
||||
raise ValueError('tail bytes after command stream')
|
||||
return cmds
|
||||
```
|
||||
|
||||
### Texm (header + mips + Page)
|
||||
|
||||
```python
|
||||
def parse_texm(raw: bytes):
|
||||
magic, w, h, mips, f4, f5, unk6, fmt = struct.unpack_from('<8I', raw, 0)
|
||||
assert magic == 0x6D786554 # 'Texm'
|
||||
bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444) else 4)
|
||||
pix_sum = 0
|
||||
mw, mh = w, h
|
||||
for _ in range(mips):
|
||||
pix_sum += mw * mh
|
||||
mw = max(1, mw >> 1)
|
||||
mh = max(1, mh >> 1)
|
||||
off = 32 + (1024 if fmt == 0 else 0) + bpp * pix_sum
|
||||
page = None
|
||||
if off + 8 <= len(raw) and raw[off:off+4] == b'Page':
|
||||
n = struct.unpack_from('<I', raw, off + 4)[0]
|
||||
page = [struct.unpack_from('<4h', raw, off + 8 + i * 8) for i in range(n)]
|
||||
return (w, h, mips, fmt, f4, f5, unk6, page)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Часть 6. Остаточные семантические вопросы
|
||||
|
||||
Пункты ниже **не блокируют 1:1-парсинг/рендер/интерполяцию** (все бинарные структуры уже определены), но их человеко‑читаемая трактовка может быть уточнена дополнительно.
|
||||
|
||||
## 6.1. Batch table — смысл `unk4/unk6/unk14`
|
||||
|
||||
Физическое расположение полей известно, но доменное имя/назначение не зафиксировано:
|
||||
|
||||
- `unk4` (`+0x04`)
|
||||
- `unk6` (`+0x06`)
|
||||
- `unk14` (`+0x0E`)
|
||||
|
||||
## 6.2. Node flags и имена групп
|
||||
|
||||
- Биты в `Res1.hdr0` используются в ряде рантайм‑веток, но их «геймдизайн‑имена» неизвестны.
|
||||
- Для group‑индекса `0..4` не найдено текстовых label'ов в ресурсах; для совместимости нужно сохранять числовой индекс как есть.
|
||||
|
||||
## 6.3. Slot tail `unk30..unk40`
|
||||
|
||||
Хвост слота (`+0x30..+0x43`, `5×uint32`) стабильно присутствует в формате, но движок не делает явной семантической декомпозиции этих пяти слов в path'ах загрузки/рендера/коллизии.
|
||||
|
||||
## 6.4. Effect command payload semantics
|
||||
|
||||
Container/stream формально полностью восстановлен (header, opcode, размеры, инстанцирование). Остаётся необязательная задача: дать «человеко‑читаемые» имена каждому полю внутри payload конкретных opcode.
|
||||
|
||||
## 6.5. Поля `TexmHeader.flags4/flags5/unk6`
|
||||
|
||||
Бинарный layout и декодер известны, но значения этих трёх полей в контенте используются контекстно; для 1:1 достаточно хранить/восстанавливать их без модификации.
|
||||
|
||||
## 6.6. Что пока не хватает для полноценного обратного экспорта (`OBJ -> MSH/NRes`)
|
||||
|
||||
Ниже перечислено то, что нужно закрыть для **lossless round-trip** и 1:1‑поведения при импорте внешней геометрии обратно в формат игры.
|
||||
|
||||
### A) Неполная «авторская» семантика бинарных таблиц
|
||||
|
||||
1. `Res2` header (`первые 0x8C`): не зафиксированы все поля и правила их вычисления при генерации нового файла (а не copy-through из оригинала).
|
||||
2. `Res7` tri-descriptor: для 16‑байтной записи декодирован базовый каркас, но остаётся неформализованной часть служебных бит/полей, нужных для стабильной генерации adjacency/служебной топологии.
|
||||
3. `Res13` поля `unk4/unk6/unk14`: для парсинга достаточно, но для генерации «канонических» значений из голого `OBJ` правила не определены.
|
||||
4. `Res2` slot tail (`unk30..unk40`): семантика не разложена, поэтому при экспорте новых ассетов нет детерминированной формулы заполнения.
|
||||
|
||||
### B) Анимационный path ещё не закрыт как writer
|
||||
|
||||
1. Нужен полный writer для `Res8/Res19`:
|
||||
- точная спецификация байтового формата на запись;
|
||||
- правила генерации mapping (`Res19`) по узлам/кадрам;
|
||||
- жёсткая фиксация округления как в x87 path (включая edge-case на границах кадра).
|
||||
2. Правила биндинга узлов/строк (`Res10`) и `slotFlags` к runtime‑сущностям пока описаны частично и требуют формализации именно для импорта новых данных.
|
||||
|
||||
### C) Материалы, текстуры, эффекты для «полного ассета»
|
||||
|
||||
1. Для `Texm` не завершён writer, покрывающий все используемые режимы (включая palette path, mip-chain, `Page`, и правила заполнения служебных полей).
|
||||
2. Для `FXID` известен контейнер/длины команд, но не завершена field-level семантика payload всех opcode для генерации новых эффектов, эквивалентных оригинальному пайплайну.
|
||||
3. Экспорт только `OBJ` покрывает геометрию; для игрового ассета нужен sidecar-слой (материалы/текстуры/эффекты/анимация), иначе импорт неизбежно неполный.
|
||||
|
||||
### D) Что это означает на практике
|
||||
|
||||
1. `OBJ -> MSH` сейчас реалистичен как **ограниченный static-экспорт** (позиции/индексы/часть batch/slot структуры).
|
||||
2. `OBJ -> полноценный игровой ресурс` (без потерь, с поведением 1:1) пока недостижим без закрытия пунктов A/B/C.
|
||||
3. До закрытия пунктов A/B/C рекомендуется использовать режим:
|
||||
- геометрия экспортируется из `OBJ`;
|
||||
- неизвестные/служебные поля берутся copy-through из референсного оригинального ассета той же структуры.
|
||||
@@ -1,314 +1,22 @@
|
||||
# 3D модели (MSH / AniMesh)
|
||||
# Форматы 3D-ресурсов движка NGI
|
||||
|
||||
Документ описывает **модельные ресурсы** старого движка по результатам анализа `AniMesh.dll` и сопутствующих библиотек.
|
||||
Этот документ теперь является обзором и точкой входа в набор отдельных спецификаций.
|
||||
|
||||
---
|
||||
## Структура спецификаций
|
||||
|
||||
## 0) Термины
|
||||
1. [MSH core](msh-core.md) — геометрия, узлы, батчи, LOD, slot-матрица.
|
||||
2. [MSH animation](msh-animation.md) — `Res8`, `Res19`, выбор ключей и интерполяция.
|
||||
3. [Materials + Texm](materials-texm.md) — материалы, текстуры, палитры, `WEAR`, `LIGHTMAPS`, `Texm`.
|
||||
4. [FXID](fxid.md) — контейнер эффекта и команды runtime-потока.
|
||||
5. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру.
|
||||
6. [Runtime pipeline](runtime-pipeline.md) — межмодульное поведение движка в кадре.
|
||||
7. [3D implementation notes](msh-notes.md) — контрольные заметки, декодирование и открытые вопросы.
|
||||
|
||||
- **Модель** — набор геометрии + иерархия узлов (node/bone) + дополнительные таблицы (батчи/слоты/треки).
|
||||
- **Node** — узел иерархии (часть/кость). Визуально: “кусок” модели, которому можно применять transform (rigid).
|
||||
- **LOD** — уровень детализации. В коде обнаружены **3 уровня LOD: 0..2** (и “текущий” LOD через `-1`).
|
||||
- **Slot** — связка “(node, LOD, group) → диапазоны геометрии + bounds”.
|
||||
- **Batch** — рендер‑пакет: “материал + диапазон индексов + baseVertex”.
|
||||
## Связанные спецификации
|
||||
|
||||
---
|
||||
- [NRes / RsLi](nres.md)
|
||||
|
||||
## 1) Архитектура модели в движке (как это реально рисуется)
|
||||
## Принцип декомпозиции
|
||||
|
||||
### 1.1 Рендер‑модель: rigid‑скининг (по узлам), без весов вершин
|
||||
|
||||
По коду выборка геометрии делается так:
|
||||
|
||||
1. Выбирается **LOD** (в объекте хранится `current_lod`, см. `sub_100124D0`).
|
||||
2. Для каждого узла **node** выбирается **slot** по `(nodeIndex, group, lod)`:
|
||||
- Если lod == `-1`, то берётся `current_lod`.
|
||||
- Если в node‑таблице хранится `0xFFFF`, slot отсутствует.
|
||||
3. Slot задаёт **диапазон batch’ей** (`batch_start`, `batch_count`).
|
||||
4. Рендерер получает batch‑диапазон и для каждого batch делает `DrawIndexedPrimitive` (абстрактный вызов через графический интерфейс движка), используя:
|
||||
- `baseVertex`
|
||||
- `indexStart`
|
||||
- `indexCount`
|
||||
- материал (индекс материала/шейдера в batch’е)
|
||||
|
||||
**Важно:** в “модельном” формате не видно классических skin weights (4 bone indices + 4 weights). Это очень похоже на “rigid parts”: каждый batch/часть привязан к одному узлу (или группе узлов) и рендерится с матрицей этого узла.
|
||||
|
||||
---
|
||||
|
||||
## 2) Набор ресурсов модели (что лежит внутри “файла модели”)
|
||||
|
||||
Ниже перечислены ресурсы, которые гарантированно встречаются в загрузчике `AniMesh`:
|
||||
|
||||
- **Res1** — node table (таблица узлов и LOD‑слотов).
|
||||
- **Res2** — header + slot table (слоты и bounds).
|
||||
- **Res3** — vertex positions (float3).
|
||||
- **Res4** — packed normals (4 байта на вершину; s8‑компоненты).
|
||||
- **Res5** — packed UV0 (4 байта на вершину; s16 U,V).
|
||||
- **Res6** — index buffer (u16 индексы).
|
||||
- **Res7** — triangle descriptors (по 16 байт на треугольник).
|
||||
- **Res8** — keyframes / anim track data (используется в интерполяции).
|
||||
- **Res10** — string table (имена: материалов/узлов/частей — точный маппинг зависит от вызывающей стороны).
|
||||
- **Res13** — batch table (по 20 байт на batch).
|
||||
- **Res19** — дополнительная таблица для анимации/маппинга (используется вместе с Res8; точная семантика пока не восстановлена).
|
||||
|
||||
Опциональные (встречаются условно, если ресурс присутствует):
|
||||
|
||||
- **Res15** — per‑vertex stream, stride 8 (семантика не подтверждена).
|
||||
- **Res16** — per‑vertex stream, stride 8, при этом движок создаёт **два “под‑потока” по 4 байта** (см. ниже).
|
||||
- **Res18** — per‑vertex stream, stride 4 (семантика не подтверждена).
|
||||
- **Res20** — дополнительный массив + отдельное “count/meta” поле из заголовка ресурса.
|
||||
|
||||
---
|
||||
|
||||
## 3) Декодирование базовой геометрии
|
||||
|
||||
### 3.1 Positions (Res3)
|
||||
|
||||
- Структура: массив `float3`.
|
||||
- Stride: `12`.
|
||||
- Использование: `pos = *(float3*)(res3 + 12*vertexIndex)`.
|
||||
|
||||
### 3.2 UV0 (Res5) — packed s16
|
||||
|
||||
- Stride: `4`.
|
||||
- Формат: `int16 u, int16 v`
|
||||
- Нормализация (из кода): `uv = (u, v) * (1/1024)`
|
||||
|
||||
То есть:
|
||||
|
||||
- `u_float = (int16)u / 1024.0`
|
||||
- `v_float = (int16)v / 1024.0`
|
||||
|
||||
### 3.3 Normals (Res4) — packed s8
|
||||
|
||||
- Stride: `4`.
|
||||
- Формат (минимально подтверждено): `int8 nx, int8 ny, int8 nz, int8 nw(?)`
|
||||
- Нормализация (из кода): множитель `1/128 = 0.0078125`
|
||||
|
||||
То есть:
|
||||
|
||||
- `n = (nx, ny, nz) / 128.0`
|
||||
|
||||
4‑й байт пока не подтверждён (встречается как паддинг/знак/индекс — нужно дальше копать).
|
||||
|
||||
---
|
||||
|
||||
## 4) Таблицы, задающие разбиение геометрии
|
||||
|
||||
### 4.1 Batch table (Res13), запись 20 байт
|
||||
|
||||
Batch используется в рендере и в обходе треугольников. Из обхода достоверно:
|
||||
|
||||
- `indexCount` читается как `u16` по смещению `+8`.
|
||||
- `indexStart` используется как **u32 по смещению `+10`** (движок читает dword и умножает на 2 для смещения в u16‑индексах).
|
||||
- `baseVertex` читается как `u32` по смещению `+16`.
|
||||
|
||||
Рекомендуемая реконструкция:
|
||||
|
||||
- `+0 u16 batchFlags` — используется для фильтрации (битовая маска).
|
||||
- `+2 u16 materialIndex` — очень похоже на индекс материала/шейдера.
|
||||
- `+4 u16 unk4`
|
||||
- `+6 u16 unk6` — **возможный** `nodeIndex` (часто именно здесь держат привязку батча к кости).
|
||||
- `+8 u16 indexCount` — число индексов (кратно 3 для треугольников).
|
||||
- `+10 u32 indexStart` — стартовый индекс в общем index buffer (в элементах u16).
|
||||
- `+14 u16 unk14` — возможно “primitive/strip mode” или ещё один флаг.
|
||||
- `+16 u32 baseVertex` — смещение вершинного индекса (в вершинах).
|
||||
|
||||
### 4.2 Triangle descriptors (Res7), запись 16 байт
|
||||
|
||||
Треугольные дескрипторы используются при итерации треугольников (коллизии/выбор/тесты):
|
||||
|
||||
- `+0 u16 triFlags` — используется для фильтрации (битовая маска)
|
||||
- Остальные поля пока не подтверждены (вероятно: доп. флаги, группа, precomputed normal, ID поверхности и т.п.)
|
||||
|
||||
**Важно:** индексы вершин треугольника берутся **из index buffer (Res6)** через `indexStart/indexCount` batch’а. TriDesc не хранит сами индексы.
|
||||
|
||||
---
|
||||
|
||||
## 5) Slot table (Res2 + смещение 140), запись 68 байт
|
||||
|
||||
Slot — ключевая структура, по которой движок:
|
||||
|
||||
- получает bounds (AABB + sphere),
|
||||
- получает диапазон batch’ей для рендера/обхода,
|
||||
- получает стартовый индекс треугольников (triStart) в TriDesc.
|
||||
|
||||
В коде Slot читается как `u16`‑поля + как `float`‑поля (AABB/sphere). Подтверждённая раскладка:
|
||||
|
||||
### 5.1 Заголовок slot (первые 8 байт)
|
||||
|
||||
- `+0 u16 triStart` — индекс первого треугольника в `Res7` (TriDesc), используемый в обходе.
|
||||
- `+2 u16 slotFlagsOrUnk` — пока не восстановлено (не путать с batchFlags/triFlags).
|
||||
- `+4 u16 batchStart` — индекс первого batch’а в `Res13`.
|
||||
- `+6 u16 batchCount` — количество batch’ей.
|
||||
|
||||
### 5.2 AABB (локальные границы, 24 байта)
|
||||
|
||||
- `+8 float aabbMin.x`
|
||||
- `+12 float aabbMin.y`
|
||||
- `+16 float aabbMin.z`
|
||||
- `+20 float aabbMax.x`
|
||||
- `+24 float aabbMax.y`
|
||||
- `+28 float aabbMax.z`
|
||||
|
||||
### 5.3 Bounding sphere (локальные границы, 16 байт)
|
||||
|
||||
- `+32 float sphereCenter.x`
|
||||
- `+36 float sphereCenter.y`
|
||||
- `+40 float sphereCenter.z`
|
||||
- `+44 float sphereRadius`
|
||||
|
||||
### 5.4 Хвост (20 байт)
|
||||
|
||||
- `+48..+67` — не используется в найденных вызовах bounds/рендера; назначение неизвестно. Возможные кандидаты: LOD‑дистанции, доп. bounds, служебные поля экспортёра.
|
||||
|
||||
---
|
||||
|
||||
## 6) Node table (Res1), запись 19 \* u16 на узел (38 байт)
|
||||
|
||||
Node table — это не “матрицы узлов”, а компактная карта слотов по LOD и группам.
|
||||
|
||||
Движок вычисляет адрес слова так:
|
||||
|
||||
`wordIndex = nodeIndex * 19 + lod * 5 + group + 4`
|
||||
|
||||
где:
|
||||
|
||||
- `lod` в диапазоне `0..2` (**три уровня LOD**)
|
||||
- `group` в диапазоне `0..4` (**пять групп слотов**)
|
||||
- если вместо `lod` передать `-1`, движок подставит `current_lod` из инстанса.
|
||||
|
||||
Из этого следует структура узла:
|
||||
|
||||
### 6.1 Заголовок узла (первые 4 u16)
|
||||
|
||||
- `u16 hdr0`
|
||||
- `u16 hdr1`
|
||||
- `u16 hdr2`
|
||||
- `u16 hdr3`
|
||||
|
||||
Семантика заголовка узла **пока не восстановлена** (кандидаты: parent/firstChild/nextSibling/flags).
|
||||
|
||||
### 6.2 SlotIndex‑матрица: 3 LOD \* 5 groups = 15 u16
|
||||
|
||||
Дальше идут 15 слов:
|
||||
|
||||
- для `lod=0`: `slotIndex[group0..4]`
|
||||
- для `lod=1`: `slotIndex[group0..4]`
|
||||
- для `lod=2`: `slotIndex[group0..4]`
|
||||
|
||||
`slotIndex` — это индекс в slot table (`Res2+140`), либо `0xFFFF` если слота нет.
|
||||
|
||||
**Группы (0..4)**: в коде чаще всего используется `group=0`. Остальные группы встречаются как параметр обхода, но назначение (например, “коллизия”, “тени”, “декали”, “альфа‑геометрия” и т.п.) пока не доказано. В документации ниже они называются просто `group`.
|
||||
|
||||
---
|
||||
|
||||
## 7) Рендер‑проход (рекомендуемая реконструкция)
|
||||
|
||||
Минимальный корректный порт рендера может повторять логику:
|
||||
|
||||
1. Определить `current_lod` (0..2) для модели (по дистанции/настройкам).
|
||||
2. Для каждого node:
|
||||
- взять slotIndex = node.slotIndex[current_lod][group=0]
|
||||
- если `0xFFFF` — пропустить
|
||||
- slot = slotTable[slotIndex]
|
||||
3. Для slot’а:
|
||||
- для i in `0 .. slot.batchCount-1`:
|
||||
- batch = batchTable[slot.batchStart + i]
|
||||
- применить материал `materialIndex`
|
||||
- применить transform узла (как минимум: rootTransform \* nodeTransform)
|
||||
- нарисовать индексированную геометрию:
|
||||
- baseVertex = batch.baseVertex
|
||||
- indexStart = batch.indexStart
|
||||
- indexCount = batch.indexCount
|
||||
4. Для culling:
|
||||
- использовать slot AABB/sphere, трансформируя их матрицей узла/инстанса.
|
||||
- при неравномерном scale радиус сферы масштабируется по `max(scaleX, scaleY, scaleZ)` (так делает оригинальный код).
|
||||
|
||||
---
|
||||
|
||||
## 8) Обход треугольников (коллизия/пикинг/дебаг)
|
||||
|
||||
В движке есть универсальный обход:
|
||||
|
||||
- Идём по slot’ам (node, lod, group).
|
||||
- Для каждого slot:
|
||||
- for batch in slot.batchRange:
|
||||
- получаем индексы из Res6 (indexStart/indexCount)
|
||||
- triCount = (indexCount + 2) / 3
|
||||
- параллельно двигаем указатель TriDesc начиная с `triStart`
|
||||
- для каждого треугольника:
|
||||
- читаем `triFlags` (TriDesc[0])
|
||||
- фильтруем по маскам
|
||||
- вызываем callback, которому доступны:
|
||||
- triDesc (16 байт)
|
||||
- три индекса (из index buffer)
|
||||
- три позиции (из Res3 через baseVertex + индекс)
|
||||
|
||||
---
|
||||
|
||||
## 9) Опциональные vertex streams (Res15/16/18/20) — текущий статус
|
||||
|
||||
Эти ресурсы загружаются, но в найденных местах пока **нет однозначного декодера**. Что точно видно по загрузчику:
|
||||
|
||||
- **Res15**: stride 8, массив на вершину.
|
||||
- кандидаты: `float2 uv1` (lightmap), либо 4×`int16` (2 UV‑пары), либо что‑то иное.
|
||||
|
||||
- **Res16**: stride 8, но движок создаёт два “под‑потока”:
|
||||
- streamA = res16 + 0, stride 8
|
||||
- streamB = res16 + 4, stride 8 Это сильно похоже на “два packed‑вектора по 4 байта”, например `tangent` и `bitangent` (s8×4).
|
||||
|
||||
- **Res18**: stride 4, массив на вершину.
|
||||
- кандидаты: `D3DCOLOR` (RGBA), либо packed‑параметры освещения/окклюзии.
|
||||
|
||||
- **Res20**: присутствует не всегда; отдельно читается `count/meta` поле из заголовка ресурса.
|
||||
- кандидаты: дополнительная таблица соответствий (vertex remap), либо ускорение для эффектов/деформаций.
|
||||
|
||||
---
|
||||
|
||||
## 10) Как “создавать” модели (экспортёр / конвертер) — практическая рекомендация
|
||||
|
||||
Чтобы собрать совместимый формат (минимум, достаточный для рендера и коллизии), нужно:
|
||||
|
||||
1. Сформировать единый массив вершин:
|
||||
- positions (Res3)
|
||||
- packed normals (Res4) — если хотите сохранить оригинальную упаковку
|
||||
- packed uv0 (Res5)
|
||||
|
||||
2. Сформировать index buffer (Res6) u16.
|
||||
|
||||
3. Сформировать batch table (Res13):
|
||||
- сгруппировать треугольники по (материал, узел/часть, режим)
|
||||
- записать `baseVertex`, `indexStart`, `indexCount`
|
||||
- заполнить неизвестные поля нулями (пока нет доказанной семантики).
|
||||
|
||||
4. Сформировать triangle descriptor table (Res7):
|
||||
- на каждый треугольник 16 байт
|
||||
- минимум: `triFlags=0`
|
||||
- остальное — 0.
|
||||
|
||||
5. Сформировать slot table (Res2+140):
|
||||
- для каждого (node, lod, group) задать:
|
||||
- triStart (индекс начала triDesc для обхода)
|
||||
- batchStart/batchCount
|
||||
- AABB и bounding sphere в локальных координатах узла/части
|
||||
- неиспользуемые поля хвоста = 0.
|
||||
|
||||
6. Сформировать node table (Res1):
|
||||
- для каждого node:
|
||||
- 4 заголовочных u16 (пока можно 0)
|
||||
- 15 slotIndex’ов (LOD0..2 × group0..4), `0xFFFF` где нет слота.
|
||||
|
||||
7. Анимацию/Res8/Res19/Res11:
|
||||
- если не нужна — можно отсутствующими, но надо проверить, что загрузчик/движок допускает “статическую” модель без этих ресурсов (в оригинале много логики завязано на них).
|
||||
|
||||
---
|
||||
|
||||
## 11) Что ещё нужно восстановить, чтобы документация стала “закрывающей” на 100%
|
||||
|
||||
1. Точная семантика `batch.unk6` (вероятный nodeIndex) и `batch.unk4/unk14`.
|
||||
2. Полная раскладка TriDesc16 (кроме triFlags).
|
||||
3. Назначение `slotFlagsOrUnk`.
|
||||
4. Семантика групп `group=1..4` в node‑таблице.
|
||||
5. Назначение и декодирование Res15/Res16/Res18/Res20.
|
||||
6. Связь строковой таблицы (Res10) с материалами/узлами (кто именно как индексирует строки).
|
||||
- Форматы и контейнеры документируются отдельно, чтобы их можно было верифицировать и править независимо.
|
||||
- Runtime-пайплайн вынесен в отдельный документ, потому что пересекает несколько DLL и не является форматом на диске.
|
||||
|
||||
5
docs/specs/network.md
Normal file
5
docs/specs/network.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Network system
|
||||
|
||||
Документ описывает сетевую подсистему: протокол обмена, синхронизацию состояния и сетевую архитектуру (client-server/P2P).
|
||||
|
||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Net.dll`.
|
||||
@@ -10,9 +10,9 @@
|
||||
|
||||
---
|
||||
|
||||
# Часть 1. Формат NRes
|
||||
## Часть 1. Формат NRes
|
||||
|
||||
## 1.1. Общая структура файла
|
||||
### 1.1. Общая структура файла
|
||||
|
||||
```
|
||||
┌──────────────────────────┐ Смещение 0
|
||||
@@ -28,7 +28,7 @@
|
||||
└──────────────────────────┘ Смещение = total_size
|
||||
```
|
||||
|
||||
## 1.2. Заголовок файла (16 байт)
|
||||
### 1.2. Заголовок файла (16 байт)
|
||||
|
||||
| Смещение | Размер | Тип | Значение | Описание |
|
||||
| -------- | ------ | ------- | ------------------- | ------------------------------------ |
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
**Валидация при открытии:** магическая сигнатура и версия должны совпадать точно. Поле `total_size` (смещение 12) **проверяется на равенство** с фактическим размером файла (`GetFileSize`). Если значения не совпадают — файл отклоняется.
|
||||
|
||||
## 1.3. Положение каталога в файле
|
||||
### 1.3. Положение каталога в файле
|
||||
|
||||
Каталог располагается в самом конце файла. Его смещение вычисляется по формуле:
|
||||
|
||||
@@ -49,7 +49,7 @@ directory_offset = total_size - entry_count × 64
|
||||
|
||||
Данные ресурсов занимают пространство между заголовком (16 байт) и каталогом.
|
||||
|
||||
## 1.4. Запись каталога (64 байта)
|
||||
### 1.4. Запись каталога (64 байта)
|
||||
|
||||
Каждая запись каталога занимает ровно **64 байта** (0x40):
|
||||
|
||||
@@ -64,23 +64,23 @@ directory_offset = total_size - entry_count × 64
|
||||
| 56 | 4 | uint32 | Смещение данных от начала файла |
|
||||
| 60 | 4 | uint32 | Индекс сортировки (для двоичного поиска по имени) |
|
||||
|
||||
### Поле «Имя файла» (смещение 20, 36 байт)
|
||||
#### Поле «Имя файла» (смещение 20, 36 байт)
|
||||
|
||||
- Максимальная длина имени: **35 символов** + 1 байт null-терминатор.
|
||||
- При записи поле сначала обнуляется (`memset(0, 36 байт)`), затем копируется имя (`strncpy`, макс. 35 символов).
|
||||
- Поиск по имени выполняется **без учёта регистра** (`_strcmpi`).
|
||||
|
||||
### Поле «Индекс сортировки» (смещение 60)
|
||||
#### Поле «Индекс сортировки» (смещение 60)
|
||||
|
||||
Используется для **двоичного поиска по имени**. Содержит индекс оригинальной записи, отсортированной в алфавитном порядке (регистронезависимо). Индекс строится при сохранении файла функцией `sub_10013260` с помощью **пузырьковой сортировки** по именам.
|
||||
|
||||
**Алгоритм поиска** (`sub_10011E60`): классический двоичный поиск по отсортированному массиву индексов. Возвращает оригинальный индекс записи или `-1` при отсутствии.
|
||||
|
||||
### Поле «Смещение данных» (смещение 56)
|
||||
#### Поле «Смещение данных» (смещение 56)
|
||||
|
||||
Абсолютное смещение от начала файла. Данные читаются из mapped view: `pointer = mapped_base + data_offset`.
|
||||
|
||||
## 1.5. Выравнивание данных
|
||||
### 1.5. Выравнивание данных
|
||||
|
||||
При добавлении ресурса его данные записываются последовательно, после чего выполняется **выравнивание по 8-байтной границе**:
|
||||
|
||||
@@ -93,7 +93,7 @@ padding = ((data_size + 7) & ~7) - data_size;
|
||||
|
||||
При изменении размера данных ресурса выполняется сдвиг всех последующих данных и обновление смещений всех затронутых записей каталога.
|
||||
|
||||
## 1.6. Создание файла (API `niCreateResFile`)
|
||||
### 1.6. Создание файла (API `niCreateResFile`)
|
||||
|
||||
При создании нового файла:
|
||||
|
||||
@@ -107,7 +107,7 @@ padding = ((data_size + 7) & ~7) - data_size;
|
||||
3. Индексы сортировки пересчитываются.
|
||||
4. Каталог записей записывается в конец файла.
|
||||
|
||||
## 1.7. Режимы сортировки каталога
|
||||
### 1.7. Режимы сортировки каталога
|
||||
|
||||
Функция `sub_10012560` поддерживает 12 режимов сортировки (0–11):
|
||||
|
||||
@@ -126,7 +126,7 @@ padding = ((data_size + 7) & ~7) - data_size;
|
||||
| 10 | По (атрибут 1, имя) |
|
||||
| 11 | По (атрибут 2, имя) |
|
||||
|
||||
## 1.8. Операция `niOpenResFileEx` — флаги открытия
|
||||
### 1.8. Операция `niOpenResFileEx` — флаги открытия
|
||||
|
||||
Второй параметр — битовые флаги:
|
||||
|
||||
@@ -137,7 +137,7 @@ padding = ((data_size + 7) & ~7) - data_size;
|
||||
| 2 | 0x04 | Пометить файл как «кэшируемый» (не выгружать при refcount=0) |
|
||||
| 3 | 0x08 | Raw-режим: не проверять заголовок NRes, трактовать весь файл как единый ресурс |
|
||||
|
||||
## 1.9. Виртуальное касание страниц
|
||||
### 1.9. Виртуальное касание страниц
|
||||
|
||||
Функция `sub_100197D0` выполняет «касание» страниц памяти для принудительной загрузки из memory-mapped файла. Она обходит адресное пространство с шагом 4096 байт (размер страницы), начиная с 0x10000 (64 КБ):
|
||||
|
||||
@@ -149,9 +149,9 @@ for (result = 0x10000; result < size; result += 4096);
|
||||
|
||||
---
|
||||
|
||||
# Часть 2. Формат RsLi
|
||||
## Часть 2. Формат RsLi
|
||||
|
||||
## 2.1. Общая структура файла
|
||||
### 2.1. Общая структура файла
|
||||
|
||||
```
|
||||
┌───────────────────────────────┐ Смещение 0
|
||||
@@ -168,7 +168,7 @@ for (result = 0x10000; result < size; result += 4096);
|
||||
└───────────────────────────────┘
|
||||
```
|
||||
|
||||
## 2.2. Заголовок файла (32 байта)
|
||||
### 2.2. Заголовок файла (32 байта)
|
||||
|
||||
| Смещение | Размер | Тип | Значение | Описание |
|
||||
| -------- | ------ | ------- | ----------------- | --------------------------------------------- |
|
||||
@@ -182,16 +182,16 @@ for (result = 0x10000; result < size; result += 4096);
|
||||
| 20 | 4 | uint32 | — | **Начальное состояние XOR-шифра** (seed) |
|
||||
| 24 | 8 | — | — | Зарезервировано |
|
||||
|
||||
### Флаг предсортировки (смещение 14)
|
||||
#### Флаг предсортировки (смещение 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-шифр таблицы записей
|
||||
### 2.3. XOR-шифр таблицы записей
|
||||
|
||||
Таблица записей начинается со смещения 32 и зашифрована поточным XOR-шифром. Ключ инициализируется из DWORD по смещению 20 заголовка.
|
||||
|
||||
### Начальное состояние
|
||||
#### Начальное состояние
|
||||
|
||||
```
|
||||
seed = *(uint32*)(header + 20)
|
||||
@@ -199,7 +199,7 @@ lo = seed & 0xFF // Младший байт
|
||||
hi = (seed >> 8) & 0xFF // Второй байт
|
||||
```
|
||||
|
||||
### Алгоритм дешифровки (побайтовый)
|
||||
#### Алгоритм дешифровки (побайтовый)
|
||||
|
||||
Для каждого зашифрованного байта `encrypted[i]`, начиная с `i = 0`:
|
||||
|
||||
@@ -225,7 +225,7 @@ def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
|
||||
|
||||
Этот же алгоритм используется для шифрования данных ресурсов с методом XOR (флаги 0x20, 0x60, 0xA0), но с другим начальным ключом из записи.
|
||||
|
||||
## 2.4. Запись таблицы (32 байта, на диске, до дешифровки)
|
||||
### 2.4. Запись таблицы (32 байта, на диске, до дешифровки)
|
||||
|
||||
После дешифровки каждая запись имеет следующую структуру:
|
||||
|
||||
@@ -239,13 +239,13 @@ def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
|
||||
| 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)
|
||||
#### Поле `sort_to_original[i]` (смещение 18)
|
||||
|
||||
Это **не “свойство записи”**, а элемент таблицы индексов, по которой `rsFind` делает двоичный поиск:
|
||||
|
||||
@@ -254,7 +254,7 @@ def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
|
||||
|
||||
Поиск выполняется **двоичным поиском** по этой таблице, с фолбэком на **линейный поиск** если двоичный не нашёл (поведение `rsFind`).
|
||||
|
||||
## 2.5. Поле флагов (смещение 16 записи)
|
||||
### 2.5. Поле флагов (смещение 16 записи)
|
||||
|
||||
Биты поля флагов кодируют метод сжатия и дополнительные атрибуты:
|
||||
|
||||
@@ -263,7 +263,7 @@ def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
|
||||
Бит [6] (маска 0x040): Флаг realloc (буфер декомпрессии может быть больше)
|
||||
```
|
||||
|
||||
### Методы сжатия (биты 8–5, маска 0x1E0)
|
||||
#### Методы сжатия (биты 8–5, маска 0x1E0)
|
||||
|
||||
| Значение | Hex | Описание |
|
||||
| -------- | ----- | --------------------------------------- |
|
||||
@@ -281,13 +281,13 @@ def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
|
||||
- для 0x60 вернётся 0x40,
|
||||
- для 0xA0 вернётся 0x80.
|
||||
|
||||
### Бит 0x40 (выделение +0x12 и последующее `realloc`)
|
||||
#### Бит 0x40 (выделение +0x12 и последующее `realloc`)
|
||||
|
||||
Бит 0x40 проверяется отдельно (`flags & 0x40`). Если он установлен, выходной буфер выделяется с запасом `+0x12` (18 байт), а после распаковки вызывается `realloc` для усечения до точного `unpacked_size`.
|
||||
|
||||
Важно: этот же бит входит в код методов 0x40/0x60, поэтому для них поведение “+0x12 и shrink” включено автоматически.
|
||||
|
||||
## 2.6. Размеры данных
|
||||
### 2.6. Размеры данных
|
||||
|
||||
В каждой записи на диске хранятся оба значения:
|
||||
|
||||
@@ -300,7 +300,7 @@ def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
|
||||
|
||||
Практический нюанс для метода `0x100` (Deflate): в реальных игровых данных встречается запись, где `packed_size` указывает на диапазон до `EOF + 1`. Поток успешно декодируется и без последнего байта; это похоже на lookahead-поведение декодера.
|
||||
|
||||
## 2.7. Опциональный трейлер медиа (6 байт)
|
||||
### 2.7. Опциональный трейлер медиа (6 байт)
|
||||
|
||||
При открытии с флагом `a2 & 2`:
|
||||
|
||||
@@ -313,9 +313,9 @@ def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
|
||||
|
||||
---
|
||||
|
||||
# Часть 3. Алгоритмы сжатия (формат RsLi)
|
||||
## Часть 3. Алгоритмы сжатия (формат RsLi)
|
||||
|
||||
## 3.1. XOR-шифр данных (метод 0x20)
|
||||
### 3.1. XOR-шифр данных (метод 0x20)
|
||||
|
||||
Алгоритм идентичен XOR‑шифру таблицы записей (раздел 2.3), но начальный ключ берётся из `entry[i].sort_to_original` (смещение 18 записи, младшие 16 бит).
|
||||
|
||||
@@ -324,7 +324,7 @@ def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
|
||||
- В ветке **0x20** движок XOR‑ит ровно `unpacked_size` байт (и ожидает, что поток данных имеет ту же длину; на практике `packed_size == unpacked_size`).
|
||||
- В ветках **0x60/0xA0** XOR применяется к **упакованному** потоку длиной `packed_size` перед декомпрессией.
|
||||
|
||||
### Инициализация
|
||||
#### Инициализация
|
||||
|
||||
```
|
||||
key16 = (uint16)entry.sort_to_original // int16 на диске по смещению 18
|
||||
@@ -332,7 +332,7 @@ lo = key16 & 0xFF
|
||||
hi = (key16 >> 8) & 0xFF
|
||||
```
|
||||
|
||||
### Дешифровка (псевдокод)
|
||||
#### Дешифровка (псевдокод)
|
||||
|
||||
```
|
||||
for i in range(N): # N = unpacked_size (для 0x20) или packed_size (для 0x60/0xA0)
|
||||
@@ -341,11 +341,11 @@ for i in range(N): # N = unpacked_size (для 0x20) или pack
|
||||
hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
|
||||
```
|
||||
|
||||
## 3.2. LZSS — простой вариант (метод 0x40)
|
||||
### 3.2. LZSS — простой вариант (метод 0x40)
|
||||
|
||||
Классический алгоритм LZSS (Lempel-Ziv-Storer-Szymanski) с кольцевым буфером.
|
||||
|
||||
### Параметры
|
||||
#### Параметры
|
||||
|
||||
| Параметр | Значение |
|
||||
| ----------------------------- | ------------------ |
|
||||
@@ -355,7 +355,7 @@ for i in range(N): # N = unpacked_size (для 0x20) или pack
|
||||
| Минимальная длина совпадения | 3 |
|
||||
| Максимальная длина совпадения | 18 (4 бита + 3) |
|
||||
|
||||
### Алгоритм декомпрессии
|
||||
#### Алгоритм декомпрессии
|
||||
|
||||
```
|
||||
Инициализация:
|
||||
@@ -400,7 +400,7 @@ for i in range(N): # N = unpacked_size (для 0x20) или pack
|
||||
4. flags_bits_remaining -= 1
|
||||
```
|
||||
|
||||
### Подробная раскладка пары ссылки (2 байта)
|
||||
#### Подробная раскладка пары ссылки (2 байта)
|
||||
|
||||
```
|
||||
Байт 0 (low): OOOOOOOO (биты [7:0] смещения)
|
||||
@@ -410,11 +410,11 @@ offset = low | ((high & 0xF0) << 4) // Диапазон: 0–4095
|
||||
length = (high & 0x0F) + 3 // Диапазон: 3–18
|
||||
```
|
||||
|
||||
## 3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80)
|
||||
### 3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80)
|
||||
|
||||
Расширенный вариант LZSS, где литералы и длины совпадений кодируются с помощью адаптивного дерева Хаффмана.
|
||||
|
||||
### Параметры
|
||||
#### Параметры
|
||||
|
||||
| Параметр | Значение |
|
||||
| -------------------------------- | ------------------------------ |
|
||||
@@ -427,7 +427,7 @@ length = (high & 0x0F) + 3 // Диапазон: 3–18
|
||||
| Начальная длина | 3 (при символе 256) |
|
||||
| Максимальная длина | 60 (при символе 313) |
|
||||
|
||||
### Дерево Хаффмана
|
||||
#### Дерево Хаффмана
|
||||
|
||||
Дерево строится как **адаптивное** (dynamic, self-adjusting):
|
||||
|
||||
@@ -437,7 +437,7 @@ length = (high & 0x0F) + 3 // Диапазон: 3–18
|
||||
- После декодирования каждого символа дерево **обновляется** (функция `sub_1001B0AE`): вес узла инкрементируется, и при нарушении порядка узлы **переставляются** для поддержания свойства.
|
||||
- При достижении суммарного веса **0x8000 (32768)** — все веса **делятся на 2** (с округлением вверх) и дерево полностью перестраивается.
|
||||
|
||||
### Кодирование позиции
|
||||
#### Кодирование позиции
|
||||
|
||||
Позиция в кольцевом буфере кодируется с помощью **d-кода** (таблица дистанций):
|
||||
|
||||
@@ -455,7 +455,7 @@ length = (high & 0x0F) + 3 // Диапазон: 3–18
|
||||
{ 0x20, 0x30, 0x40, 0x30, 0x30, 0x10 }
|
||||
```
|
||||
|
||||
### Алгоритм декомпрессии (высокоуровневый)
|
||||
#### Алгоритм декомпрессии (высокоуровневый)
|
||||
|
||||
```
|
||||
Инициализация:
|
||||
@@ -489,11 +489,11 @@ length = (high & 0x0F) + 3 // Диапазон: 3–18
|
||||
5. Если выходной буфер заполнен → завершить
|
||||
```
|
||||
|
||||
## 3.4. XOR + LZSS (методы 0x60 и 0xA0)
|
||||
### 3.4. XOR + LZSS (методы 0x60 и 0xA0)
|
||||
|
||||
Комбинированный метод: сначала XOR-дешифровка, затем LZSS-декомпрессия.
|
||||
|
||||
### Алгоритм
|
||||
#### Алгоритм
|
||||
|
||||
1. Выделить временный буфер размером `compressed_size` (поле из записи, смещение 28).
|
||||
2. Дешифровать сжатые данные XOR-шифром (раздел 3.1) с ключом из записи во временный буфер.
|
||||
@@ -503,22 +503,22 @@ length = (high & 0x0F) + 3 // Диапазон: 3–18
|
||||
- **0x60** — XOR + простой LZSS (раздел 3.2)
|
||||
- **0xA0** — XOR + LZSS с Хаффманом (раздел 3.3)
|
||||
|
||||
### Начальное состояние XOR для данных
|
||||
#### Начальное состояние XOR для данных
|
||||
|
||||
При комбинированном методе seed берётся из поля по смещению 20 записи (4-байтный). Однако ключ обрабатывается как 16-битный: `lo = seed & 0xFF`, `hi = (seed >> 8) & 0xFF`.
|
||||
|
||||
## 3.5. Deflate (метод 0x100)
|
||||
### 3.5. Deflate (метод 0x100)
|
||||
|
||||
Полноценная реализация алгоритма **Deflate** (RFC 1951) с блочной структурой.
|
||||
|
||||
### Общая структура
|
||||
#### Общая структура
|
||||
|
||||
Данные состоят из последовательности блоков. Каждый блок начинается с:
|
||||
|
||||
- **1 бит** — `is_final`: признак последнего блока
|
||||
- **2 бита** — `block_type`: тип блока
|
||||
|
||||
### Типы блоков
|
||||
#### Типы блоков
|
||||
|
||||
| block_type | Описание | Функция |
|
||||
| ---------- | --------------------------- | ---------------- |
|
||||
@@ -527,7 +527,7 @@ length = (high & 0x0F) + 3 // Диапазон: 3–18
|
||||
| 2 | Динамические коды Хаффмана | `sub_1001AA30` |
|
||||
| 3 | Зарезервировано (ошибка) | Возвращает код 2 |
|
||||
|
||||
### Блок типа 0 (stored)
|
||||
#### Блок типа 0 (stored)
|
||||
|
||||
1. Отбросить оставшиеся биты до границы байта (выравнивание).
|
||||
2. Прочитать 16 бит — `LEN` (длина блока).
|
||||
@@ -537,7 +537,7 @@ length = (high & 0x0F) + 3 // Диапазон: 3–18
|
||||
|
||||
Декомпрессор использует внутренний буфер размером **32768 байт** (0x8000). При заполнении — промежуточная запись результата.
|
||||
|
||||
### Блок типа 1 (фиксированные коды)
|
||||
#### Блок типа 1 (фиксированные коды)
|
||||
|
||||
Стандартные коды Deflate:
|
||||
|
||||
@@ -550,7 +550,7 @@ length = (high & 0x0F) + 3 // Диапазон: 3–18
|
||||
|
||||
Используются предопределённые таблицы длин и дистанций (`unk_100370AC`, `unk_1003712C` и соответствующие экстра-биты).
|
||||
|
||||
### Блок типа 2 (динамические коды)
|
||||
#### Блок типа 2 (динамические коды)
|
||||
|
||||
1. Прочитать 5 бит → `HLIT` (количество литералов/длин − 257). Диапазон: 257–286.
|
||||
2. Прочитать 5 бит → `HDIST` (количество дистанций − 1). Диапазон: 1–30.
|
||||
@@ -569,21 +569,21 @@ length = (high & 0x0F) + 3 // Диапазон: 3–18
|
||||
|
||||
Хранится в `dword_10037060`.
|
||||
|
||||
### Валидации
|
||||
#### Валидации
|
||||
|
||||
- `HLIT + 257 <= 286` (max 0x11E)
|
||||
- `HDIST + 1 <= 30` (max 0x1E)
|
||||
- При нарушении — возвращается ошибка 1.
|
||||
|
||||
## 3.6. Метод 0x00 (без сжатия)
|
||||
### 3.6. Метод 0x00 (без сжатия)
|
||||
|
||||
Данные копируются «как есть» напрямую из файла. Вызывается через указатель на функцию `dword_1003A1B8` (фактически `memcpy` или аналог).
|
||||
|
||||
---
|
||||
|
||||
# Часть 4. Внутренние структуры в памяти
|
||||
## Часть 4. Внутренние структуры в памяти
|
||||
|
||||
## 4.1. Внутренняя структура NRes-архива (opened, 0x68 байт = 104)
|
||||
### 4.1. Внутренняя структура NRes-архива (opened, 0x68 байт = 104)
|
||||
|
||||
```c
|
||||
struct NResArchive { // Размер: 0x68 (104 байта)
|
||||
@@ -601,7 +601,7 @@ struct NResArchive { // Размер: 0x68 (104 байта)
|
||||
};
|
||||
```
|
||||
|
||||
## 4.2. Внутренняя структура RsLi-архива (56 + 64 × N байт)
|
||||
### 4.2. Внутренняя структура RsLi-архива (56 + 64 × N байт)
|
||||
|
||||
```c
|
||||
struct RsLibHeader { // 56 байт (14 DWORD)
|
||||
@@ -623,7 +623,7 @@ struct RsLibHeader { // 56 байт (14 DWORD)
|
||||
// Далее следуют entry_count записей по 64 байта каждая
|
||||
```
|
||||
|
||||
### Внутренняя запись RsLi (64 байта)
|
||||
#### Внутренняя запись RsLi (64 байта)
|
||||
|
||||
```c
|
||||
struct RsLibEntry { // 64 байта (16 DWORD)
|
||||
@@ -643,9 +643,9 @@ struct RsLibEntry { // 64 байта (16 DWORD)
|
||||
|
||||
---
|
||||
|
||||
# Часть 5. Экспортируемые API-функции
|
||||
## Часть 5. Экспортируемые API-функции
|
||||
|
||||
## 5.1. NRes API
|
||||
### 5.1. NRes API
|
||||
|
||||
| Функция | Описание |
|
||||
| ------------------------------ | ------------------------------------------------------------------------- |
|
||||
@@ -654,7 +654,7 @@ struct RsLibEntry { // 64 байта (16 DWORD)
|
||||
| `niOpenResInMem(ptr, size)` | Открыть NRes-архив из памяти |
|
||||
| `niCreateResFile(path)` | Создать/открыть NRes-архив для записи |
|
||||
|
||||
## 5.2. RsLi API
|
||||
### 5.2. RsLi API
|
||||
|
||||
| Функция | Описание |
|
||||
| ------------------------------- | -------------------------------------------------------- |
|
||||
@@ -675,38 +675,38 @@ struct RsLibEntry { // 64 байта (16 DWORD)
|
||||
|
||||
---
|
||||
|
||||
# Часть 6. Контрольные заметки для реализации
|
||||
## Часть 6. Контрольные заметки для реализации
|
||||
|
||||
## 6.1. Кодировки и регистр
|
||||
### 6.1. Кодировки и регистр
|
||||
|
||||
- **NRes**: имена хранятся **как есть** (case-insensitive при поиске через `_strcmpi`).
|
||||
- **RsLi**: имена хранятся в **верхнем регистре**. Перед поиском запрос приводится к верхнему регистру (`_strupr`). Сравнение — через `strcmp` (case-sensitive для уже uppercase строк).
|
||||
|
||||
## 6.2. Порядок байт
|
||||
### 6.2. Порядок байт
|
||||
|
||||
Все значения хранятся в **little-endian** порядке (платформа x86/Win32).
|
||||
|
||||
## 6.3. Выравнивание
|
||||
### 6.3. Выравнивание
|
||||
|
||||
- **NRes**: данные каждого ресурса выровнены по границе **8 байт** (0-padding между файлами).
|
||||
- **RsLi**: выравнивание данных не описано в коде (данные идут подряд).
|
||||
|
||||
## 6.4. Размер записей на диске
|
||||
### 6.4. Размер записей на диске
|
||||
|
||||
- **NRes**: каталог — **64 байта** на запись, расположен в конце файла.
|
||||
- **RsLi**: таблица — **32 байта** на запись (зашифрованная), расположена в начале файла (сразу после 32-байтного заголовка).
|
||||
|
||||
## 6.5. Кэширование и memory mapping
|
||||
### 6.5. Кэширование и memory mapping
|
||||
|
||||
Оба формата используют Windows Memory-Mapped Files (`CreateFileMapping` + `MapViewOfFile`). NRes-архивы организованы в глобальный **связный список** (`dword_1003A66C`) со счётчиком ссылок и таймером неактивности (10 секунд = 0x2710 мс). При refcount == 0 и истечении таймера архив автоматически выгружается (если не установлен флаг `is_cacheable`).
|
||||
|
||||
## 6.6. Размер seed XOR
|
||||
### 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. Эмпирическая проверка на данных игры
|
||||
### 6.7. Эмпирическая проверка на данных игры
|
||||
|
||||
- Найдено архивов по сигнатуре: **122** (`NRes`: 120, `RsLi`: 2).
|
||||
- Выполнен полный roundtrip `unpack -> pack -> byte-compare`: **122/122** архивов совпали побайтно.
|
||||
|
||||
123
docs/specs/runtime-pipeline.md
Normal file
123
docs/specs/runtime-pipeline.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Runtime pipeline
|
||||
|
||||
Документ фиксирует runtime-поведение движка: кто кого вызывает в кадре, как проходят рендер, коллизия и подключение эффектов.
|
||||
|
||||
---
|
||||
|
||||
## 1.15. Алгоритм рендера модели (реконструкция)
|
||||
|
||||
```
|
||||
Вход: model, instanceTransform, cameraFrustum
|
||||
|
||||
1. Определить current_lod ∈ {0, 1, 2} (по дистанции до камеры / настройкам).
|
||||
|
||||
2. Для каждого node (nodeIndex = 0 .. nodeCount−1):
|
||||
a. Вычислить nodeTransform = instanceTransform × nodeLocalTransform
|
||||
|
||||
b. slotIndex = nodeTable[nodeIndex].slotMatrix[current_lod][group=0]
|
||||
если slotIndex == 0xFFFF → пропустить узел
|
||||
|
||||
c. slot = slotTable[slotIndex]
|
||||
|
||||
d. // Frustum culling:
|
||||
transformedAABB = transform(slot.aabb, nodeTransform)
|
||||
если transformedAABB вне cameraFrustum → пропустить
|
||||
|
||||
// Альтернативно по сфере:
|
||||
transformedCenter = nodeTransform × slot.sphereCenter
|
||||
scaledRadius = slot.sphereRadius × max(scaleX, scaleY, scaleZ)
|
||||
если сфера вне frustum → пропустить
|
||||
|
||||
e. Для i = 0 .. slot.batchCount − 1:
|
||||
batch = batchTable[slot.batchStart + i]
|
||||
|
||||
// Фильтрация по batchFlags (если нужна)
|
||||
|
||||
// Установить материал:
|
||||
setMaterial(batch.materialIndex)
|
||||
|
||||
// Установить transform:
|
||||
setWorldMatrix(nodeTransform)
|
||||
|
||||
// Нарисовать:
|
||||
DrawIndexedPrimitive(
|
||||
baseVertex = batch.baseVertex,
|
||||
indexStart = batch.indexStart,
|
||||
indexCount = batch.indexCount,
|
||||
primitiveType = TRIANGLE_LIST
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1.16. Алгоритм обхода треугольников (коллизия / пикинг)
|
||||
|
||||
```
|
||||
Вход: model, nodeIndex, lod, group, filterMask, callback
|
||||
|
||||
1. slotIndex = nodeTable[nodeIndex].slotMatrix[lod][group]
|
||||
если slotIndex == 0xFFFF → выход
|
||||
|
||||
2. slot = slotTable[slotIndex]
|
||||
triDescIndex = slot.triStart
|
||||
|
||||
3. Для каждого batch в диапазоне [slot.batchStart .. slot.batchStart + slot.batchCount − 1]:
|
||||
batch = batchTable[batchIndex]
|
||||
triCount = batch.indexCount / 3 // округление: (indexCount + 2) / 3
|
||||
|
||||
Для t = 0 .. triCount − 1:
|
||||
triDesc = triDescTable[triDescIndex]
|
||||
|
||||
// Фильтрация:
|
||||
если (triDesc.triFlags & filterMask) → пропустить
|
||||
|
||||
// Получить индексы вершин:
|
||||
idx0 = indexBuffer[batch.indexStart + t*3 + 0] + batch.baseVertex
|
||||
idx1 = indexBuffer[batch.indexStart + t*3 + 1] + batch.baseVertex
|
||||
idx2 = indexBuffer[batch.indexStart + t*3 + 2] + batch.baseVertex
|
||||
|
||||
// Получить позиции:
|
||||
p0 = positions[idx0]
|
||||
p1 = positions[idx1]
|
||||
p2 = positions[idx2]
|
||||
|
||||
callback(triDesc, idx0, idx1, idx2, p0, p1, p2)
|
||||
|
||||
triDescIndex += 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 3.1. Архитектурный обзор
|
||||
|
||||
Подсистема эффектов реализована в `Effect.dll` и интегрирована в рендер через `Terrain.dll`.
|
||||
|
||||
### Экспорты Effect.dll
|
||||
|
||||
| Функция | Описание |
|
||||
|----------------------|--------------------------------------------------------|
|
||||
| `CreateFxManager` | Создать менеджер эффектов (3 параметра: int, int, int) |
|
||||
| `InitializeSettings` | Инициализировать настройки эффектов |
|
||||
|
||||
`CreateFxManager` возвращает объект‑менеджер, который регистрируется в движке и управляет всеми эффектами.
|
||||
|
||||
### Телеметрия из Terrain.dll
|
||||
|
||||
Terrain.dll содержит отладочную статистику рендера:
|
||||
|
||||
```
|
||||
"Rendered meshes : %d"
|
||||
"Rendered primitives : %d"
|
||||
"Rendered faces : %d"
|
||||
"Rendered particles/batches : %d/%d"
|
||||
```
|
||||
|
||||
Из этого следует:
|
||||
|
||||
- Частицы рендерятся **батчами** (группами).
|
||||
- Статистика частиц отделена от статистики мешей.
|
||||
- Частицы интегрированы в общий 3D‑рендер‑пайплайн.
|
||||
|
||||
5
docs/specs/sound.md
Normal file
5
docs/specs/sound.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Sound system
|
||||
|
||||
Документ описывает аудиоподсистему: форматы звуковых ресурсов, воспроизведение эффектов и голосов, а также интеграцию со звуковым API.
|
||||
|
||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга звуковых модулей движка.
|
||||
32
docs/specs/terrain-map-loading.md
Normal file
32
docs/specs/terrain-map-loading.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Terrain + map loading
|
||||
|
||||
Документ описывает подсистему ландшафта и привязку terrain-данных к миру.
|
||||
|
||||
---
|
||||
|
||||
## 4.1. Обзор
|
||||
|
||||
`Terrain.dll` отвечает за рендер ландшафта (terrain), включая:
|
||||
|
||||
- Рендер мешей ландшафта (`"Rendered meshes"`, `"Rendered primitives"`, `"Rendered faces"`).
|
||||
- Рендер частиц (`"Rendered particles/batches"`).
|
||||
- Создание текстур (`"CTexture::CTexture()"` — конструктор текстуры).
|
||||
- Микротекстуры (`"Unable to find microtexture mapping"`).
|
||||
|
||||
## 4.2. Текстуры ландшафта
|
||||
|
||||
В Terrain.dll присутствует конструктор текстуры `CTexture::CTexture()` со следующими проверками:
|
||||
|
||||
- Валидация размера текстуры (`"Unsupported texture size"`).
|
||||
- Создание D3D‑текстуры (`"Unable to create texture"`).
|
||||
|
||||
Ландшафт использует **микротекстуры** (micro‑texture mapping chunks) — маленькие повторяющиеся текстуры, тайлящиеся по поверхности.
|
||||
|
||||
## 4.3. Защита от пустых примитивов
|
||||
|
||||
Terrain.dll содержит проверки:
|
||||
|
||||
- `"Rendering empty primitive!"` — перед первым вызовом отрисовки.
|
||||
- `"Rendering empty primitive2!"` — перед вторым вызовом отрисовки.
|
||||
|
||||
Это подтверждает многопроходный рендер (как минимум 2 прохода для ландшафта).
|
||||
@@ -1,90 +0,0 @@
|
||||
# Текстуры и материалы
|
||||
|
||||
На текущем этапе в дизассемблированных библиотеках **не найден полный декодер формата текстурного файла** (нет явных парсеров DDS/TGA/BMP и т.п.). Поэтому документ пока фиксирует:
|
||||
|
||||
- что можно достоверно вывести по рендер‑конфигу,
|
||||
- что видно по структурам модели (materialIndex),
|
||||
- какие места требуют дальнейшего анализа.
|
||||
|
||||
---
|
||||
|
||||
## 1) Материал в модели
|
||||
|
||||
В batch table модели (см. документацию по MSH/AniMesh) есть поле, очень похожее на:
|
||||
|
||||
- `materialIndex: u16` (batch + 2)
|
||||
|
||||
Это индекс, по которому рендерер выбирает:
|
||||
|
||||
- текстуру(ы),
|
||||
- параметры (blend, alpha test, двухтекстурность и т.п.),
|
||||
- “шейдер/пайплайн” (в терминах оригинального рендера — набор state’ов).
|
||||
|
||||
**Где лежит таблица материалов** (внутри модели или глобально) — требует подтверждения:
|
||||
|
||||
- вероятный кандидат — отдельный ресурс/таблица, на которую `materialIndex` ссылается.
|
||||
- строковая таблица `Res10` может хранить имена материалов/текстур, но маппинг не доказан.
|
||||
|
||||
---
|
||||
|
||||
## 2) Переключатели рендера, влияющие на текстуры (из Ngi32.dll)
|
||||
|
||||
В `Ngi32.dll` есть набор runtime‑настроек (похоже, читаются из системных настроек/INI/registry), которые сильно влияют на текстурный пайплайн:
|
||||
|
||||
- `DisableMipmap`
|
||||
- `DisableBilinear`
|
||||
- `DisableTrilinear`
|
||||
- `DisableMultiTexturing`
|
||||
- `Disable32bitTextures` / `Force16bitTextures`
|
||||
- `ForceSoftware`
|
||||
- `ForceNoFiltering`
|
||||
- `ForceHWTnL`
|
||||
- `ForceNoHWTnL`
|
||||
|
||||
Практический вывод для порта:
|
||||
|
||||
- движок может работать **без мипмапов**, **без фильтрации**, и даже **без multitexturing**.
|
||||
|
||||
---
|
||||
|
||||
## 3) “Две текстуры” и дополнительные UV‑потоки
|
||||
|
||||
В загрузчике модели присутствуют дополнительные per‑vertex ресурсы:
|
||||
|
||||
- Res15 (stride 8) — кандидат на UV1 (lightmap/second layer)
|
||||
- Res16 (stride 8, split в 2×4) — кандидат на tangent/bitangent (normal mapping)
|
||||
- Res18 (stride 4) — кандидат на vertex color / AO
|
||||
|
||||
Если материал реально поддерживает:
|
||||
|
||||
- вторую текстуру (detail map, lightmap),
|
||||
- нормалмапы,
|
||||
|
||||
то где‑то должен быть код:
|
||||
|
||||
- который выбирает эти потоки как входные атрибуты вершинного шейдера/пайплайна,
|
||||
- который активирует multi‑texturing.
|
||||
|
||||
Сейчас в найденных фрагментах это ещё **не подтверждено**, но структура данных “просится” именно туда.
|
||||
|
||||
---
|
||||
|
||||
## 4) Что нужно найти дальше (чтобы написать полноценную спецификацию материалов/текстур)
|
||||
|
||||
1. Место, где `materialIndex` разворачивается в набор render states:
|
||||
- alpha blending / alpha test
|
||||
- z‑write/z‑test
|
||||
- culling
|
||||
- 1‑pass vs 2‑pass (multi‑texturing)
|
||||
2. Формат записи “material record”:
|
||||
- какие поля
|
||||
- ссылки на текстуры (ID, имя, индекс в таблице)
|
||||
3. Формат “texture asset”:
|
||||
- где хранится (внутри NRes или отдельным файлом)
|
||||
- компрессия/палитра/мip’ы
|
||||
4. Привязка строковой таблицы `Res10` к материалам:
|
||||
- это имена материалов?
|
||||
- это имена текстур?
|
||||
- или это имена узлов/анимаций?
|
||||
|
||||
До подтверждения этих пунктов разумнее держать документацию как “архитектурную карту”, а не как точный байтовый формат.
|
||||
5
docs/specs/ui.md
Normal file
5
docs/specs/ui.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# UI system
|
||||
|
||||
Документ описывает интерфейсную подсистему: ресурсы UI, шрифты, minimap, layout и обработку пользовательского ввода в интерфейсе.
|
||||
|
||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга UI-компонентов движка.
|
||||
23
mkdocs.yml
23
mkdocs.yml
@@ -10,12 +10,12 @@ repo_name: valentineus/fparkan
|
||||
repo_url: https://github.com/valentineus/fparkan
|
||||
|
||||
# Copyright
|
||||
copyright: Copyright © 2023 — 2024 Valentin Popov
|
||||
copyright: Copyright © 2023 — 2026 Valentin Popov
|
||||
|
||||
# Configuration
|
||||
theme:
|
||||
name: material
|
||||
language: en
|
||||
language: ru
|
||||
palette:
|
||||
scheme: slate
|
||||
|
||||
@@ -23,10 +23,23 @@ theme:
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Specs:
|
||||
- 3D implementation notes: specs/msh-notes.md
|
||||
- AI system: specs/ai.md
|
||||
- ArealMap: specs/arealmap.md
|
||||
- Behavior system: specs/behavior.md
|
||||
- Control system: specs/control.md
|
||||
- FXID: specs/fxid.md
|
||||
- Materials + Texm: specs/materials-texm.md
|
||||
- Missions: specs/missions.md
|
||||
- MSH animation: specs/msh-animation.md
|
||||
- MSH core: specs/msh-core.md
|
||||
- Network system: specs/network.md
|
||||
- NRes / RsLi: specs/nres.md
|
||||
- 3D модели: specs/msh.md
|
||||
- Текстуры и материалы: specs/textures.md
|
||||
- Эффекты и частицы: specs/effects.md
|
||||
- Runtime pipeline: specs/runtime-pipeline.md
|
||||
- Sound system: specs/sound.md
|
||||
- Terrain + map loading: specs/terrain-map-loading.md
|
||||
- UI system: specs/ui.md
|
||||
- Форматы 3D‑ресурсов (обзор): specs/msh.md
|
||||
|
||||
# Additional configuration
|
||||
extra:
|
||||
|
||||
2
testdata/nres/.gitignore
vendored
Normal file
2
testdata/nres/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
testdata/rsli/.gitignore
vendored
Normal file
2
testdata/rsli/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
130
tools/README.md
130
tools/README.md
@@ -69,3 +69,133 @@ python3 tools/archive_roundtrip_validator.py validate \
|
||||
- `entries/*.bin` — payload-файлы.
|
||||
|
||||
Имена файлов в `entries` включают индекс записи, поэтому коллизии одинаковых имён внутри архива обрабатываются корректно.
|
||||
|
||||
## `init_testdata.py`
|
||||
|
||||
Скрипт инициализирует тестовые данные по сигнатурам архивов из спецификации:
|
||||
|
||||
- `NRes` (`4E 52 65 73`);
|
||||
- `RsLi` (`NL 00 01`).
|
||||
|
||||
Что делает утилита:
|
||||
|
||||
- рекурсивно сканирует все файлы в `--input`;
|
||||
- копирует найденные `NRes` в `--output/nres/`;
|
||||
- копирует найденные `RsLi` в `--output/rsli/`;
|
||||
- сохраняет относительный путь исходного файла внутри целевого каталога;
|
||||
- создаёт целевые каталоги автоматически, если их нет.
|
||||
|
||||
Базовый запуск:
|
||||
|
||||
```bash
|
||||
python3 tools/init_testdata.py --input tmp/gamedata --output testdata
|
||||
```
|
||||
|
||||
Если целевой файл уже существует, скрипт спрашивает подтверждение перезаписи (`yes/no/all/quit`).
|
||||
|
||||
Для перезаписи без вопросов используйте `--force`:
|
||||
|
||||
```bash
|
||||
python3 tools/init_testdata.py --input tmp/gamedata --output testdata --force
|
||||
```
|
||||
|
||||
Проверки надёжности:
|
||||
|
||||
- `--input` должен существовать и быть каталогом;
|
||||
- если `--output` указывает на существующий файл, скрипт завершится с ошибкой;
|
||||
- если `--output` расположен внутри `--input`, каталог вывода исключается из сканирования;
|
||||
- если `stdin` неинтерактивный и требуется перезапись, нужно явно указать `--force`.
|
||||
|
||||
## `msh_doc_validator.py`
|
||||
|
||||
Скрипт валидирует ключевые инварианты из документации `/Users/valentineus/Developer/personal/fparkan/docs/specs/msh.md` на реальных данных.
|
||||
|
||||
Проверяемые группы:
|
||||
|
||||
- модели `*.msh` (вложенные `NRes` в архивах `NRes`);
|
||||
- текстуры `Texm` (`type_id = 0x6D786554`);
|
||||
- эффекты `FXID` (`type_id = 0x44495846`).
|
||||
|
||||
Что проверяет для моделей:
|
||||
|
||||
- обязательные ресурсы (`Res1/2/3/6/13`) и известные опциональные (`Res4/5/7/8/10/15/16/18/19`);
|
||||
- `size/attr1/attr3` и шаги структур по таблицам;
|
||||
- диапазоны индексов, батчей и ссылок между таблицами;
|
||||
- разбор `Res10` как `len + bytes + NUL` для каждого узла;
|
||||
- матрицу слотов в `Res1` (LOD/group) и границы по `Res2/Res7/Res13/Res19`.
|
||||
|
||||
Быстрый запуск:
|
||||
|
||||
```bash
|
||||
python3 tools/msh_doc_validator.py scan --input testdata/nres
|
||||
python3 tools/msh_doc_validator.py validate --input testdata/nres --print-limit 20
|
||||
```
|
||||
|
||||
С отчётом в JSON:
|
||||
|
||||
```bash
|
||||
python3 tools/msh_doc_validator.py validate \
|
||||
--input testdata/nres \
|
||||
--report tmp/msh_validation_report.json \
|
||||
--fail-on-warnings
|
||||
```
|
||||
|
||||
## `msh_preview_renderer.py`
|
||||
|
||||
Примитивный программный рендерер моделей `*.msh` без внешних зависимостей.
|
||||
|
||||
- вход: архив `NRes` (например `animals.rlb`) или прямой payload модели;
|
||||
- выход: изображение `PPM` (`P6`);
|
||||
- использует `Res3` (позиции), `Res6` (индексы), `Res13` (батчи), `Res1/Res2` (выбор слотов по `lod/group`).
|
||||
|
||||
Показать доступные модели в архиве:
|
||||
|
||||
```bash
|
||||
python3 tools/msh_preview_renderer.py list-models --archive testdata/nres/animals.rlb
|
||||
```
|
||||
|
||||
Сгенерировать тестовый рендер:
|
||||
|
||||
```bash
|
||||
python3 tools/msh_preview_renderer.py render \
|
||||
--archive testdata/nres/animals.rlb \
|
||||
--model A_L_01.msh \
|
||||
--output tmp/renders/A_L_01.ppm \
|
||||
--width 800 \
|
||||
--height 600 \
|
||||
--lod 0 \
|
||||
--group 0 \
|
||||
--wireframe
|
||||
```
|
||||
|
||||
Ограничения:
|
||||
|
||||
- инструмент предназначен для smoke-теста геометрии, а не для пиксельно-точного рендера движка;
|
||||
- текстуры/материалы/эффектные проходы не эмулируются.
|
||||
|
||||
## `msh_export_obj.py`
|
||||
|
||||
Экспортирует геометрию `*.msh` в `Wavefront OBJ`, чтобы открыть модель в Blender/MeshLab.
|
||||
|
||||
- вход: `NRes` архив (например `animals.rlb`) или прямой payload модели;
|
||||
- выбор геометрии: через `Res1` slot matrix (`lod/group`) как в рендерере;
|
||||
- опция `--all-batches` экспортирует все батчи, игнорируя slot matrix.
|
||||
|
||||
Показать модели в архиве:
|
||||
|
||||
```bash
|
||||
python3 tools/msh_export_obj.py list-models --archive testdata/nres/animals.rlb
|
||||
```
|
||||
|
||||
Экспорт в OBJ:
|
||||
|
||||
```bash
|
||||
python3 tools/msh_export_obj.py export \
|
||||
--archive testdata/nres/animals.rlb \
|
||||
--model A_L_01.msh \
|
||||
--output tmp/renders/A_L_01.obj \
|
||||
--lod 0 \
|
||||
--group 0
|
||||
```
|
||||
|
||||
Файл `OBJ` можно открыть напрямую в Blender (`File -> Import -> Wavefront (.obj)`).
|
||||
|
||||
204
tools/init_testdata.py
Normal file
204
tools/init_testdata.py
Normal file
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Initialize test data folders by archive signatures.
|
||||
|
||||
The script scans all files in --input and copies matching archives into:
|
||||
--output/nres/<relative path>
|
||||
--output/rsli/<relative path>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
MAGIC_NRES = b"NRes"
|
||||
MAGIC_RSLI = b"NL\x00\x01"
|
||||
|
||||
|
||||
def is_relative_to(path: Path, base: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(base)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def detect_archive_type(path: Path) -> str | None:
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
magic = handle.read(4)
|
||||
except OSError as exc:
|
||||
print(f"[warn] cannot read {path}: {exc}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
if magic == MAGIC_NRES:
|
||||
return "nres"
|
||||
if magic == MAGIC_RSLI:
|
||||
return "rsli"
|
||||
return None
|
||||
|
||||
|
||||
def scan_archives(input_root: Path, excluded_root: Path | None) -> list[tuple[Path, str]]:
|
||||
found: list[tuple[Path, str]] = []
|
||||
for path in sorted(input_root.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if excluded_root and is_relative_to(path.resolve(), excluded_root):
|
||||
continue
|
||||
|
||||
archive_type = detect_archive_type(path)
|
||||
if archive_type:
|
||||
found.append((path, archive_type))
|
||||
return found
|
||||
|
||||
|
||||
def confirm_overwrite(path: Path) -> str:
|
||||
prompt = (
|
||||
f"File exists: {path}\n"
|
||||
"Overwrite? [y]es / [n]o / [a]ll / [q]uit (default: n): "
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
answer = input(prompt).strip().lower()
|
||||
except EOFError:
|
||||
return "quit"
|
||||
|
||||
if answer in {"", "n", "no"}:
|
||||
return "no"
|
||||
if answer in {"y", "yes"}:
|
||||
return "yes"
|
||||
if answer in {"a", "all"}:
|
||||
return "all"
|
||||
if answer in {"q", "quit"}:
|
||||
return "quit"
|
||||
print("Please answer with y, n, a, or q.")
|
||||
|
||||
|
||||
def copy_archives(
|
||||
archives: list[tuple[Path, str]],
|
||||
input_root: Path,
|
||||
output_root: Path,
|
||||
force: bool,
|
||||
) -> int:
|
||||
copied = 0
|
||||
skipped = 0
|
||||
overwritten = 0
|
||||
overwrite_all = force
|
||||
|
||||
type_counts = {"nres": 0, "rsli": 0}
|
||||
for _, archive_type in archives:
|
||||
type_counts[archive_type] += 1
|
||||
|
||||
print(
|
||||
f"Found archives: total={len(archives)}, "
|
||||
f"nres={type_counts['nres']}, rsli={type_counts['rsli']}"
|
||||
)
|
||||
|
||||
for source, archive_type in archives:
|
||||
rel_path = source.relative_to(input_root)
|
||||
destination = output_root / archive_type / rel_path
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if destination.exists():
|
||||
if destination.is_dir():
|
||||
print(
|
||||
f"[error] destination is a directory, expected file: {destination}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
if not overwrite_all:
|
||||
if not sys.stdin.isatty():
|
||||
print(
|
||||
"[error] destination file exists but stdin is not interactive. "
|
||||
"Use --force to overwrite without prompts.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
decision = confirm_overwrite(destination)
|
||||
if decision == "quit":
|
||||
print("Aborted by user.")
|
||||
return 130
|
||||
if decision == "no":
|
||||
skipped += 1
|
||||
continue
|
||||
if decision == "all":
|
||||
overwrite_all = True
|
||||
|
||||
overwritten += 1
|
||||
|
||||
try:
|
||||
shutil.copy2(source, destination)
|
||||
except OSError as exc:
|
||||
print(f"[error] failed to copy {source} -> {destination}: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
copied += 1
|
||||
|
||||
print(
|
||||
f"Done: copied={copied}, overwritten={overwritten}, skipped={skipped}, "
|
||||
f"output={output_root}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Initialize test data by scanning NRes/RsLi signatures."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input",
|
||||
required=True,
|
||||
help="Input directory to scan recursively.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
required=True,
|
||||
help="Output root directory (archives go to nres/ and rsli/ subdirs).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Overwrite destination files without confirmation prompts.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
|
||||
input_root = Path(args.input)
|
||||
if not input_root.exists():
|
||||
print(f"[error] input directory does not exist: {input_root}", file=sys.stderr)
|
||||
return 2
|
||||
if not input_root.is_dir():
|
||||
print(f"[error] input path is not a directory: {input_root}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
output_root = Path(args.output)
|
||||
if output_root.exists() and not output_root.is_dir():
|
||||
print(f"[error] output path exists and is not a directory: {output_root}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
input_resolved = input_root.resolve()
|
||||
output_resolved = output_root.resolve()
|
||||
if input_resolved == output_resolved:
|
||||
print("[error] input and output directories must be different.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
excluded_root: Path | None = None
|
||||
if is_relative_to(output_resolved, input_resolved):
|
||||
excluded_root = output_resolved
|
||||
print(f"Notice: output is inside input, skipping scan under: {excluded_root}")
|
||||
|
||||
archives = scan_archives(input_root, excluded_root)
|
||||
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
return copy_archives(archives, input_root, output_root, force=args.force)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
1000
tools/msh_doc_validator.py
Normal file
1000
tools/msh_doc_validator.py
Normal file
File diff suppressed because it is too large
Load Diff
357
tools/msh_export_obj.py
Normal file
357
tools/msh_export_obj.py
Normal file
@@ -0,0 +1,357 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Export NGI MSH geometry to Wavefront OBJ.
|
||||
|
||||
The exporter is intended for inspection/debugging and uses the same
|
||||
batch/slot selection logic as msh_preview_renderer.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import archive_roundtrip_validator as arv
|
||||
|
||||
MAGIC_NRES = b"NRes"
|
||||
|
||||
|
||||
def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes:
|
||||
start = int(entry["data_offset"])
|
||||
end = start + int(entry["size"])
|
||||
return blob[start:end]
|
||||
|
||||
|
||||
def _parse_nres(blob: bytes, source: str) -> dict[str, Any]:
|
||||
if blob[:4] != MAGIC_NRES:
|
||||
raise RuntimeError(f"{source}: not an NRes payload")
|
||||
return arv.parse_nres(blob, source=source)
|
||||
|
||||
|
||||
def _by_type(entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]:
|
||||
out: dict[int, list[dict[str, Any]]] = {}
|
||||
for row in entries:
|
||||
out.setdefault(int(row["type_id"]), []).append(row)
|
||||
return out
|
||||
|
||||
|
||||
def _get_single(by_type: dict[int, list[dict[str, Any]]], type_id: int, label: str) -> dict[str, Any]:
|
||||
rows = by_type.get(type_id, [])
|
||||
if not rows:
|
||||
raise RuntimeError(f"missing resource type {type_id} ({label})")
|
||||
return rows[0]
|
||||
|
||||
|
||||
def _pick_model_payload(archive_path: Path, model_name: str | None) -> tuple[bytes, str]:
|
||||
root_blob = archive_path.read_bytes()
|
||||
parsed = _parse_nres(root_blob, str(archive_path))
|
||||
|
||||
msh_entries = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")]
|
||||
if msh_entries:
|
||||
chosen: dict[str, Any] | None = None
|
||||
if model_name:
|
||||
model_l = model_name.lower()
|
||||
for row in msh_entries:
|
||||
name_l = str(row["name"]).lower()
|
||||
if name_l == model_l:
|
||||
chosen = row
|
||||
break
|
||||
if chosen is None:
|
||||
for row in msh_entries:
|
||||
if str(row["name"]).lower().startswith(model_l):
|
||||
chosen = row
|
||||
break
|
||||
else:
|
||||
chosen = msh_entries[0]
|
||||
|
||||
if chosen is None:
|
||||
names = ", ".join(str(row["name"]) for row in msh_entries[:12])
|
||||
raise RuntimeError(
|
||||
f"model '{model_name}' not found in {archive_path}. Available: {names}"
|
||||
)
|
||||
return _entry_payload(root_blob, chosen), str(chosen["name"])
|
||||
|
||||
by_type = _by_type(parsed["entries"])
|
||||
if all(k in by_type for k in (1, 2, 3, 6, 13)):
|
||||
return root_blob, archive_path.name
|
||||
|
||||
raise RuntimeError(
|
||||
f"{archive_path} does not contain .msh entries and does not look like a direct model payload"
|
||||
)
|
||||
|
||||
|
||||
def _extract_geometry(
|
||||
model_blob: bytes,
|
||||
*,
|
||||
lod: int,
|
||||
group: int,
|
||||
max_faces: int,
|
||||
all_batches: bool,
|
||||
) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]], dict[str, int]]:
|
||||
parsed = _parse_nres(model_blob, "<model>")
|
||||
by_type = _by_type(parsed["entries"])
|
||||
|
||||
res1 = _get_single(by_type, 1, "Res1")
|
||||
res2 = _get_single(by_type, 2, "Res2")
|
||||
res3 = _get_single(by_type, 3, "Res3")
|
||||
res6 = _get_single(by_type, 6, "Res6")
|
||||
res13 = _get_single(by_type, 13, "Res13")
|
||||
|
||||
pos_blob = _entry_payload(model_blob, res3)
|
||||
if len(pos_blob) % 12 != 0:
|
||||
raise RuntimeError(f"Res3 size is not divisible by 12: {len(pos_blob)}")
|
||||
vertex_count = len(pos_blob) // 12
|
||||
positions = [struct.unpack_from("<3f", pos_blob, i * 12) for i in range(vertex_count)]
|
||||
|
||||
idx_blob = _entry_payload(model_blob, res6)
|
||||
if len(idx_blob) % 2 != 0:
|
||||
raise RuntimeError(f"Res6 size is not divisible by 2: {len(idx_blob)}")
|
||||
index_count = len(idx_blob) // 2
|
||||
indices = list(struct.unpack_from(f"<{index_count}H", idx_blob, 0))
|
||||
|
||||
batch_blob = _entry_payload(model_blob, res13)
|
||||
if len(batch_blob) % 20 != 0:
|
||||
raise RuntimeError(f"Res13 size is not divisible by 20: {len(batch_blob)}")
|
||||
batch_count = len(batch_blob) // 20
|
||||
batches: list[tuple[int, int, int, int]] = []
|
||||
for i in range(batch_count):
|
||||
off = i * 20
|
||||
idx_count = struct.unpack_from("<H", batch_blob, off + 8)[0]
|
||||
idx_start = struct.unpack_from("<I", batch_blob, off + 10)[0]
|
||||
base_vertex = struct.unpack_from("<I", batch_blob, off + 16)[0]
|
||||
batches.append((idx_count, idx_start, base_vertex, i))
|
||||
|
||||
res2_blob = _entry_payload(model_blob, res2)
|
||||
if len(res2_blob) < 0x8C:
|
||||
raise RuntimeError("Res2 is too small (< 0x8C)")
|
||||
slot_blob = res2_blob[0x8C:]
|
||||
if len(slot_blob) % 68 != 0:
|
||||
raise RuntimeError(f"Res2 slot area is not divisible by 68: {len(slot_blob)}")
|
||||
slot_count = len(slot_blob) // 68
|
||||
slots: list[tuple[int, int, int, int]] = []
|
||||
for i in range(slot_count):
|
||||
off = i * 68
|
||||
tri_start, tri_count, batch_start, slot_batch_count = struct.unpack_from("<4H", slot_blob, off)
|
||||
slots.append((tri_start, tri_count, batch_start, slot_batch_count))
|
||||
|
||||
res1_blob = _entry_payload(model_blob, res1)
|
||||
node_stride = int(res1["attr3"])
|
||||
node_count = int(res1["attr1"])
|
||||
node_slot_indices: list[int] = []
|
||||
if not all_batches and node_stride >= 38 and len(res1_blob) >= node_count * node_stride:
|
||||
if lod < 0 or lod > 2:
|
||||
raise RuntimeError(f"lod must be 0..2 (got {lod})")
|
||||
if group < 0 or group > 4:
|
||||
raise RuntimeError(f"group must be 0..4 (got {group})")
|
||||
matrix_index = lod * 5 + group
|
||||
for n in range(node_count):
|
||||
off = n * node_stride + 8 + matrix_index * 2
|
||||
slot_idx = struct.unpack_from("<H", res1_blob, off)[0]
|
||||
if slot_idx == 0xFFFF:
|
||||
continue
|
||||
if slot_idx >= slot_count:
|
||||
continue
|
||||
node_slot_indices.append(slot_idx)
|
||||
|
||||
faces: list[tuple[int, int, int]] = []
|
||||
used_batches = 0
|
||||
used_slots = 0
|
||||
|
||||
def append_batch(batch_idx: int) -> None:
|
||||
nonlocal used_batches
|
||||
if batch_idx < 0 or batch_idx >= len(batches):
|
||||
return
|
||||
idx_count, idx_start, base_vertex, _ = batches[batch_idx]
|
||||
if idx_count < 3:
|
||||
return
|
||||
end = idx_start + idx_count
|
||||
if end > len(indices):
|
||||
return
|
||||
used_batches += 1
|
||||
tri_count = idx_count // 3
|
||||
for t in range(tri_count):
|
||||
i0 = indices[idx_start + t * 3 + 0] + base_vertex
|
||||
i1 = indices[idx_start + t * 3 + 1] + base_vertex
|
||||
i2 = indices[idx_start + t * 3 + 2] + base_vertex
|
||||
if i0 >= vertex_count or i1 >= vertex_count or i2 >= vertex_count:
|
||||
continue
|
||||
faces.append((i0, i1, i2))
|
||||
if len(faces) >= max_faces:
|
||||
return
|
||||
|
||||
if node_slot_indices:
|
||||
for slot_idx in node_slot_indices:
|
||||
if len(faces) >= max_faces:
|
||||
break
|
||||
_tri_start, _tri_count, batch_start, slot_batch_count = slots[slot_idx]
|
||||
used_slots += 1
|
||||
for bi in range(batch_start, batch_start + slot_batch_count):
|
||||
append_batch(bi)
|
||||
if len(faces) >= max_faces:
|
||||
break
|
||||
else:
|
||||
for bi in range(batch_count):
|
||||
append_batch(bi)
|
||||
if len(faces) >= max_faces:
|
||||
break
|
||||
|
||||
if not faces:
|
||||
raise RuntimeError("no faces selected for export")
|
||||
|
||||
meta = {
|
||||
"vertex_count": vertex_count,
|
||||
"index_count": index_count,
|
||||
"batch_count": batch_count,
|
||||
"slot_count": slot_count,
|
||||
"node_count": node_count,
|
||||
"used_slots": used_slots,
|
||||
"used_batches": used_batches,
|
||||
"face_count": len(faces),
|
||||
}
|
||||
return positions, faces, meta
|
||||
|
||||
|
||||
def _compute_vertex_normals(
|
||||
positions: list[tuple[float, float, float]],
|
||||
faces: list[tuple[int, int, int]],
|
||||
) -> list[tuple[float, float, float]]:
|
||||
acc = [[0.0, 0.0, 0.0] for _ in positions]
|
||||
for i0, i1, i2 in faces:
|
||||
p0 = positions[i0]
|
||||
p1 = positions[i1]
|
||||
p2 = positions[i2]
|
||||
ux = p1[0] - p0[0]
|
||||
uy = p1[1] - p0[1]
|
||||
uz = p1[2] - p0[2]
|
||||
vx = p2[0] - p0[0]
|
||||
vy = p2[1] - p0[1]
|
||||
vz = p2[2] - p0[2]
|
||||
nx = uy * vz - uz * vy
|
||||
ny = uz * vx - ux * vz
|
||||
nz = ux * vy - uy * vx
|
||||
acc[i0][0] += nx
|
||||
acc[i0][1] += ny
|
||||
acc[i0][2] += nz
|
||||
acc[i1][0] += nx
|
||||
acc[i1][1] += ny
|
||||
acc[i1][2] += nz
|
||||
acc[i2][0] += nx
|
||||
acc[i2][1] += ny
|
||||
acc[i2][2] += nz
|
||||
|
||||
normals: list[tuple[float, float, float]] = []
|
||||
for nx, ny, nz in acc:
|
||||
ln = math.sqrt(nx * nx + ny * ny + nz * nz)
|
||||
if ln <= 1e-12:
|
||||
normals.append((0.0, 1.0, 0.0))
|
||||
else:
|
||||
normals.append((nx / ln, ny / ln, nz / ln))
|
||||
return normals
|
||||
|
||||
|
||||
def _write_obj(
|
||||
output_path: Path,
|
||||
object_name: str,
|
||||
positions: list[tuple[float, float, float]],
|
||||
faces: list[tuple[int, int, int]],
|
||||
) -> None:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
normals = _compute_vertex_normals(positions, faces)
|
||||
|
||||
with output_path.open("w", encoding="utf-8", newline="\n") as out:
|
||||
out.write("# Exported by msh_export_obj.py\n")
|
||||
out.write(f"o {object_name}\n")
|
||||
for x, y, z in positions:
|
||||
out.write(f"v {x:.9g} {y:.9g} {z:.9g}\n")
|
||||
for nx, ny, nz in normals:
|
||||
out.write(f"vn {nx:.9g} {ny:.9g} {nz:.9g}\n")
|
||||
for i0, i1, i2 in faces:
|
||||
a = i0 + 1
|
||||
b = i1 + 1
|
||||
c = i2 + 1
|
||||
out.write(f"f {a}//{a} {b}//{b} {c}//{c}\n")
|
||||
|
||||
|
||||
def cmd_list_models(args: argparse.Namespace) -> int:
|
||||
archive_path = Path(args.archive).resolve()
|
||||
blob = archive_path.read_bytes()
|
||||
parsed = _parse_nres(blob, str(archive_path))
|
||||
rows = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")]
|
||||
print(f"Archive: {archive_path}")
|
||||
print(f"MSH entries: {len(rows)}")
|
||||
for row in rows:
|
||||
print(f"- {row['name']}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_export(args: argparse.Namespace) -> int:
|
||||
archive_path = Path(args.archive).resolve()
|
||||
output_path = Path(args.output).resolve()
|
||||
|
||||
model_blob, model_label = _pick_model_payload(archive_path, args.model)
|
||||
positions, faces, meta = _extract_geometry(
|
||||
model_blob,
|
||||
lod=int(args.lod),
|
||||
group=int(args.group),
|
||||
max_faces=int(args.max_faces),
|
||||
all_batches=bool(args.all_batches),
|
||||
)
|
||||
obj_name = Path(model_label).stem or "msh_model"
|
||||
_write_obj(output_path, obj_name, positions, faces)
|
||||
|
||||
print(f"Exported model : {model_label}")
|
||||
print(f"Output OBJ : {output_path}")
|
||||
print(f"Object name : {obj_name}")
|
||||
print(
|
||||
"Geometry : "
|
||||
f"vertices={meta['vertex_count']}, faces={meta['face_count']}, "
|
||||
f"batches={meta['used_batches']}/{meta['batch_count']}, slots={meta['used_slots']}/{meta['slot_count']}"
|
||||
)
|
||||
print(
|
||||
"Mode : "
|
||||
f"lod={args.lod}, group={args.group}, all_batches={bool(args.all_batches)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Export NGI MSH geometry to Wavefront OBJ."
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
list_models = sub.add_parser("list-models", help="List .msh entries in an NRes archive.")
|
||||
list_models.add_argument("--archive", required=True, help="Path to archive (e.g. animals.rlb).")
|
||||
list_models.set_defaults(func=cmd_list_models)
|
||||
|
||||
export = sub.add_parser("export", help="Export one model to OBJ.")
|
||||
export.add_argument("--archive", required=True, help="Path to NRes archive or direct model payload.")
|
||||
export.add_argument(
|
||||
"--model",
|
||||
help="Model entry name (*.msh) inside archive. If omitted, first .msh is used.",
|
||||
)
|
||||
export.add_argument("--output", required=True, help="Output .obj path.")
|
||||
export.add_argument("--lod", type=int, default=0, help="LOD index 0..2 (default: 0).")
|
||||
export.add_argument("--group", type=int, default=0, help="Group index 0..4 (default: 0).")
|
||||
export.add_argument("--max-faces", type=int, default=120000, help="Face limit (default: 120000).")
|
||||
export.add_argument(
|
||||
"--all-batches",
|
||||
action="store_true",
|
||||
help="Ignore slot matrix selection and export all batches.",
|
||||
)
|
||||
export.set_defaults(func=cmd_export)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
return int(args.func(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
481
tools/msh_preview_renderer.py
Normal file
481
tools/msh_preview_renderer.py
Normal file
@@ -0,0 +1,481 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Primitive software renderer for NGI MSH models.
|
||||
|
||||
Output format: binary PPM (P6), no external dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import archive_roundtrip_validator as arv
|
||||
|
||||
MAGIC_NRES = b"NRes"
|
||||
|
||||
|
||||
def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes:
|
||||
start = int(entry["data_offset"])
|
||||
end = start + int(entry["size"])
|
||||
return blob[start:end]
|
||||
|
||||
|
||||
def _parse_nres(blob: bytes, source: str) -> dict[str, Any]:
|
||||
if blob[:4] != MAGIC_NRES:
|
||||
raise RuntimeError(f"{source}: not an NRes payload")
|
||||
return arv.parse_nres(blob, source=source)
|
||||
|
||||
|
||||
def _by_type(entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]:
|
||||
out: dict[int, list[dict[str, Any]]] = {}
|
||||
for row in entries:
|
||||
out.setdefault(int(row["type_id"]), []).append(row)
|
||||
return out
|
||||
|
||||
|
||||
def _pick_model_payload(archive_path: Path, model_name: str | None) -> tuple[bytes, str]:
|
||||
root_blob = archive_path.read_bytes()
|
||||
parsed = _parse_nres(root_blob, str(archive_path))
|
||||
|
||||
msh_entries = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")]
|
||||
if msh_entries:
|
||||
chosen: dict[str, Any] | None = None
|
||||
if model_name:
|
||||
model_l = model_name.lower()
|
||||
for row in msh_entries:
|
||||
name_l = str(row["name"]).lower()
|
||||
if name_l == model_l:
|
||||
chosen = row
|
||||
break
|
||||
if chosen is None:
|
||||
for row in msh_entries:
|
||||
if str(row["name"]).lower().startswith(model_l):
|
||||
chosen = row
|
||||
break
|
||||
else:
|
||||
chosen = msh_entries[0]
|
||||
|
||||
if chosen is None:
|
||||
names = ", ".join(str(row["name"]) for row in msh_entries[:12])
|
||||
raise RuntimeError(
|
||||
f"model '{model_name}' not found in {archive_path}. Available: {names}"
|
||||
)
|
||||
return _entry_payload(root_blob, chosen), str(chosen["name"])
|
||||
|
||||
# Fallback: treat file itself as a model NRes payload.
|
||||
by_type = _by_type(parsed["entries"])
|
||||
if all(k in by_type for k in (1, 2, 3, 6, 13)):
|
||||
return root_blob, archive_path.name
|
||||
|
||||
raise RuntimeError(
|
||||
f"{archive_path} does not contain .msh entries and does not look like a direct model payload"
|
||||
)
|
||||
|
||||
|
||||
def _get_single(by_type: dict[int, list[dict[str, Any]]], type_id: int, label: str) -> dict[str, Any]:
|
||||
rows = by_type.get(type_id, [])
|
||||
if not rows:
|
||||
raise RuntimeError(f"missing resource type {type_id} ({label})")
|
||||
return rows[0]
|
||||
|
||||
|
||||
def _extract_geometry(
|
||||
model_blob: bytes,
|
||||
*,
|
||||
lod: int,
|
||||
group: int,
|
||||
max_faces: int,
|
||||
) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]], dict[str, int]]:
|
||||
parsed = _parse_nres(model_blob, "<model>")
|
||||
by_type = _by_type(parsed["entries"])
|
||||
|
||||
res1 = _get_single(by_type, 1, "Res1")
|
||||
res2 = _get_single(by_type, 2, "Res2")
|
||||
res3 = _get_single(by_type, 3, "Res3")
|
||||
res6 = _get_single(by_type, 6, "Res6")
|
||||
res13 = _get_single(by_type, 13, "Res13")
|
||||
|
||||
# Positions
|
||||
pos_blob = _entry_payload(model_blob, res3)
|
||||
if len(pos_blob) % 12 != 0:
|
||||
raise RuntimeError(f"Res3 size is not divisible by 12: {len(pos_blob)}")
|
||||
vertex_count = len(pos_blob) // 12
|
||||
positions = [struct.unpack_from("<3f", pos_blob, i * 12) for i in range(vertex_count)]
|
||||
|
||||
# Indices
|
||||
idx_blob = _entry_payload(model_blob, res6)
|
||||
if len(idx_blob) % 2 != 0:
|
||||
raise RuntimeError(f"Res6 size is not divisible by 2: {len(idx_blob)}")
|
||||
index_count = len(idx_blob) // 2
|
||||
indices = list(struct.unpack_from(f"<{index_count}H", idx_blob, 0))
|
||||
|
||||
# Batches
|
||||
batch_blob = _entry_payload(model_blob, res13)
|
||||
if len(batch_blob) % 20 != 0:
|
||||
raise RuntimeError(f"Res13 size is not divisible by 20: {len(batch_blob)}")
|
||||
batch_count = len(batch_blob) // 20
|
||||
batches: list[tuple[int, int, int, int]] = []
|
||||
for i in range(batch_count):
|
||||
off = i * 20
|
||||
# Keep only fields used by renderer:
|
||||
# indexCount, indexStart, baseVertex
|
||||
idx_count = struct.unpack_from("<H", batch_blob, off + 8)[0]
|
||||
idx_start = struct.unpack_from("<I", batch_blob, off + 10)[0]
|
||||
base_vertex = struct.unpack_from("<I", batch_blob, off + 16)[0]
|
||||
batches.append((idx_count, idx_start, base_vertex, i))
|
||||
|
||||
# Slots
|
||||
res2_blob = _entry_payload(model_blob, res2)
|
||||
if len(res2_blob) < 0x8C:
|
||||
raise RuntimeError("Res2 is too small (< 0x8C)")
|
||||
slot_blob = res2_blob[0x8C:]
|
||||
if len(slot_blob) % 68 != 0:
|
||||
raise RuntimeError(f"Res2 slot area is not divisible by 68: {len(slot_blob)}")
|
||||
slot_count = len(slot_blob) // 68
|
||||
slots: list[tuple[int, int, int, int]] = []
|
||||
for i in range(slot_count):
|
||||
off = i * 68
|
||||
tri_start, tri_count, batch_start, slot_batch_count = struct.unpack_from("<4H", slot_blob, off)
|
||||
slots.append((tri_start, tri_count, batch_start, slot_batch_count))
|
||||
|
||||
# Nodes / slot matrix
|
||||
res1_blob = _entry_payload(model_blob, res1)
|
||||
node_stride = int(res1["attr3"])
|
||||
node_count = int(res1["attr1"])
|
||||
node_slot_indices: list[int] = []
|
||||
if node_stride >= 38 and len(res1_blob) >= node_count * node_stride:
|
||||
if lod < 0 or lod > 2:
|
||||
raise RuntimeError(f"lod must be 0..2 (got {lod})")
|
||||
if group < 0 or group > 4:
|
||||
raise RuntimeError(f"group must be 0..4 (got {group})")
|
||||
matrix_index = lod * 5 + group
|
||||
for n in range(node_count):
|
||||
off = n * node_stride + 8 + matrix_index * 2
|
||||
slot_idx = struct.unpack_from("<H", res1_blob, off)[0]
|
||||
if slot_idx == 0xFFFF:
|
||||
continue
|
||||
if slot_idx >= slot_count:
|
||||
continue
|
||||
node_slot_indices.append(slot_idx)
|
||||
|
||||
# Build triangle list.
|
||||
faces: list[tuple[int, int, int]] = []
|
||||
used_batches = 0
|
||||
used_slots = 0
|
||||
|
||||
def append_batch(batch_idx: int) -> None:
|
||||
nonlocal used_batches
|
||||
if batch_idx < 0 or batch_idx >= len(batches):
|
||||
return
|
||||
idx_count, idx_start, base_vertex, _ = batches[batch_idx]
|
||||
if idx_count < 3:
|
||||
return
|
||||
end = idx_start + idx_count
|
||||
if end > len(indices):
|
||||
return
|
||||
used_batches += 1
|
||||
tri_count = idx_count // 3
|
||||
for t in range(tri_count):
|
||||
i0 = indices[idx_start + t * 3 + 0] + base_vertex
|
||||
i1 = indices[idx_start + t * 3 + 1] + base_vertex
|
||||
i2 = indices[idx_start + t * 3 + 2] + base_vertex
|
||||
if i0 >= vertex_count or i1 >= vertex_count or i2 >= vertex_count:
|
||||
continue
|
||||
faces.append((i0, i1, i2))
|
||||
if len(faces) >= max_faces:
|
||||
return
|
||||
|
||||
if node_slot_indices:
|
||||
for slot_idx in node_slot_indices:
|
||||
if len(faces) >= max_faces:
|
||||
break
|
||||
_tri_start, _tri_count, batch_start, slot_batch_count = slots[slot_idx]
|
||||
used_slots += 1
|
||||
for bi in range(batch_start, batch_start + slot_batch_count):
|
||||
append_batch(bi)
|
||||
if len(faces) >= max_faces:
|
||||
break
|
||||
else:
|
||||
# Fallback if slot matrix is unavailable: draw all batches.
|
||||
for bi in range(batch_count):
|
||||
append_batch(bi)
|
||||
if len(faces) >= max_faces:
|
||||
break
|
||||
|
||||
meta = {
|
||||
"vertex_count": vertex_count,
|
||||
"index_count": index_count,
|
||||
"batch_count": batch_count,
|
||||
"slot_count": slot_count,
|
||||
"node_count": node_count,
|
||||
"used_slots": used_slots,
|
||||
"used_batches": used_batches,
|
||||
"face_count": len(faces),
|
||||
}
|
||||
if not faces:
|
||||
raise RuntimeError("no faces selected for rendering")
|
||||
return positions, faces, meta
|
||||
|
||||
|
||||
def _write_ppm(path: Path, width: int, height: int, rgb: bytearray) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("wb") as handle:
|
||||
handle.write(f"P6\n{width} {height}\n255\n".encode("ascii"))
|
||||
handle.write(rgb)
|
||||
|
||||
|
||||
def _render_software(
|
||||
positions: list[tuple[float, float, float]],
|
||||
faces: list[tuple[int, int, int]],
|
||||
*,
|
||||
width: int,
|
||||
height: int,
|
||||
yaw_deg: float,
|
||||
pitch_deg: float,
|
||||
wireframe: bool,
|
||||
) -> bytearray:
|
||||
xs = [p[0] for p in positions]
|
||||
ys = [p[1] for p in positions]
|
||||
zs = [p[2] for p in positions]
|
||||
cx = (min(xs) + max(xs)) * 0.5
|
||||
cy = (min(ys) + max(ys)) * 0.5
|
||||
cz = (min(zs) + max(zs)) * 0.5
|
||||
span = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs))
|
||||
radius = max(span * 0.5, 1e-3)
|
||||
|
||||
yaw = math.radians(yaw_deg)
|
||||
pitch = math.radians(pitch_deg)
|
||||
cyaw = math.cos(yaw)
|
||||
syaw = math.sin(yaw)
|
||||
cpitch = math.cos(pitch)
|
||||
spitch = math.sin(pitch)
|
||||
|
||||
camera_dist = radius * 3.2
|
||||
scale = min(width, height) * 0.95
|
||||
|
||||
# Transform all vertices once.
|
||||
vx: list[float] = []
|
||||
vy: list[float] = []
|
||||
vz: list[float] = []
|
||||
sx: list[float] = []
|
||||
sy: list[float] = []
|
||||
for x, y, z in positions:
|
||||
x0 = x - cx
|
||||
y0 = y - cy
|
||||
z0 = z - cz
|
||||
x1 = cyaw * x0 + syaw * z0
|
||||
z1 = -syaw * x0 + cyaw * z0
|
||||
y2 = cpitch * y0 - spitch * z1
|
||||
z2 = spitch * y0 + cpitch * z1 + camera_dist
|
||||
if z2 < 1e-3:
|
||||
z2 = 1e-3
|
||||
vx.append(x1)
|
||||
vy.append(y2)
|
||||
vz.append(z2)
|
||||
sx.append(width * 0.5 + (x1 / z2) * scale)
|
||||
sy.append(height * 0.5 - (y2 / z2) * scale)
|
||||
|
||||
rgb = bytearray([16, 18, 24] * (width * height))
|
||||
zbuf = [float("inf")] * (width * height)
|
||||
light_dir = (0.35, 0.45, 1.0)
|
||||
l_len = math.sqrt(light_dir[0] ** 2 + light_dir[1] ** 2 + light_dir[2] ** 2)
|
||||
light = (light_dir[0] / l_len, light_dir[1] / l_len, light_dir[2] / l_len)
|
||||
|
||||
def edge(ax: float, ay: float, bx: float, by: float, px: float, py: float) -> float:
|
||||
return (px - ax) * (by - ay) - (py - ay) * (bx - ax)
|
||||
|
||||
for i0, i1, i2 in faces:
|
||||
x0 = sx[i0]
|
||||
y0 = sy[i0]
|
||||
x1 = sx[i1]
|
||||
y1 = sy[i1]
|
||||
x2 = sx[i2]
|
||||
y2 = sy[i2]
|
||||
area = edge(x0, y0, x1, y1, x2, y2)
|
||||
if area == 0.0:
|
||||
continue
|
||||
|
||||
# Shading from camera-space normal.
|
||||
ux = vx[i1] - vx[i0]
|
||||
uy = vy[i1] - vy[i0]
|
||||
uz = vz[i1] - vz[i0]
|
||||
wx = vx[i2] - vx[i0]
|
||||
wy = vy[i2] - vy[i0]
|
||||
wz = vz[i2] - vz[i0]
|
||||
nx = uy * wz - uz * wy
|
||||
ny = uz * wx - ux * wz
|
||||
nz = ux * wy - uy * wx
|
||||
n_len = math.sqrt(nx * nx + ny * ny + nz * nz)
|
||||
if n_len > 0.0:
|
||||
nx /= n_len
|
||||
ny /= n_len
|
||||
nz /= n_len
|
||||
intensity = nx * light[0] + ny * light[1] + nz * light[2]
|
||||
if intensity < 0.0:
|
||||
intensity = 0.0
|
||||
shade = int(45 + 200 * intensity)
|
||||
color = (shade, shade, min(255, shade + 18))
|
||||
|
||||
minx = int(max(0, math.floor(min(x0, x1, x2))))
|
||||
maxx = int(min(width - 1, math.ceil(max(x0, x1, x2))))
|
||||
miny = int(max(0, math.floor(min(y0, y1, y2))))
|
||||
maxy = int(min(height - 1, math.ceil(max(y0, y1, y2))))
|
||||
if minx > maxx or miny > maxy:
|
||||
continue
|
||||
|
||||
z0 = vz[i0]
|
||||
z1 = vz[i1]
|
||||
z2 = vz[i2]
|
||||
|
||||
for py in range(miny, maxy + 1):
|
||||
fy = py + 0.5
|
||||
row = py * width
|
||||
for px in range(minx, maxx + 1):
|
||||
fx = px + 0.5
|
||||
w0 = edge(x1, y1, x2, y2, fx, fy)
|
||||
w1 = edge(x2, y2, x0, y0, fx, fy)
|
||||
w2 = edge(x0, y0, x1, y1, fx, fy)
|
||||
if area > 0:
|
||||
if w0 < 0 or w1 < 0 or w2 < 0:
|
||||
continue
|
||||
else:
|
||||
if w0 > 0 or w1 > 0 or w2 > 0:
|
||||
continue
|
||||
inv_area = 1.0 / area
|
||||
bz0 = w0 * inv_area
|
||||
bz1 = w1 * inv_area
|
||||
bz2 = w2 * inv_area
|
||||
depth = bz0 * z0 + bz1 * z1 + bz2 * z2
|
||||
idx = row + px
|
||||
if depth >= zbuf[idx]:
|
||||
continue
|
||||
zbuf[idx] = depth
|
||||
p = idx * 3
|
||||
rgb[p + 0] = color[0]
|
||||
rgb[p + 1] = color[1]
|
||||
rgb[p + 2] = color[2]
|
||||
|
||||
if wireframe:
|
||||
def draw_line(xa: float, ya: float, xb: float, yb: float) -> None:
|
||||
x0i = int(round(xa))
|
||||
y0i = int(round(ya))
|
||||
x1i = int(round(xb))
|
||||
y1i = int(round(yb))
|
||||
dx = abs(x1i - x0i)
|
||||
sx_step = 1 if x0i < x1i else -1
|
||||
dy = -abs(y1i - y0i)
|
||||
sy_step = 1 if y0i < y1i else -1
|
||||
err = dx + dy
|
||||
x = x0i
|
||||
y = y0i
|
||||
while True:
|
||||
if 0 <= x < width and 0 <= y < height:
|
||||
p = (y * width + x) * 3
|
||||
rgb[p + 0] = 240
|
||||
rgb[p + 1] = 245
|
||||
rgb[p + 2] = 255
|
||||
if x == x1i and y == y1i:
|
||||
break
|
||||
e2 = 2 * err
|
||||
if e2 >= dy:
|
||||
err += dy
|
||||
x += sx_step
|
||||
if e2 <= dx:
|
||||
err += dx
|
||||
y += sy_step
|
||||
|
||||
for i0, i1, i2 in faces:
|
||||
draw_line(sx[i0], sy[i0], sx[i1], sy[i1])
|
||||
draw_line(sx[i1], sy[i1], sx[i2], sy[i2])
|
||||
draw_line(sx[i2], sy[i2], sx[i0], sy[i0])
|
||||
|
||||
return rgb
|
||||
|
||||
|
||||
def cmd_list_models(args: argparse.Namespace) -> int:
|
||||
archive_path = Path(args.archive).resolve()
|
||||
blob = archive_path.read_bytes()
|
||||
parsed = _parse_nres(blob, str(archive_path))
|
||||
rows = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")]
|
||||
print(f"Archive: {archive_path}")
|
||||
print(f"MSH entries: {len(rows)}")
|
||||
for row in rows:
|
||||
print(f"- {row['name']}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_render(args: argparse.Namespace) -> int:
|
||||
archive_path = Path(args.archive).resolve()
|
||||
output_path = Path(args.output).resolve()
|
||||
|
||||
model_blob, model_label = _pick_model_payload(archive_path, args.model)
|
||||
positions, faces, meta = _extract_geometry(
|
||||
model_blob,
|
||||
lod=int(args.lod),
|
||||
group=int(args.group),
|
||||
max_faces=int(args.max_faces),
|
||||
)
|
||||
rgb = _render_software(
|
||||
positions,
|
||||
faces,
|
||||
width=int(args.width),
|
||||
height=int(args.height),
|
||||
yaw_deg=float(args.yaw),
|
||||
pitch_deg=float(args.pitch),
|
||||
wireframe=bool(args.wireframe),
|
||||
)
|
||||
_write_ppm(output_path, int(args.width), int(args.height), rgb)
|
||||
|
||||
print(f"Rendered model: {model_label}")
|
||||
print(f"Output : {output_path}")
|
||||
print(
|
||||
"Geometry : "
|
||||
f"vertices={meta['vertex_count']}, faces={meta['face_count']}, "
|
||||
f"batches={meta['used_batches']}/{meta['batch_count']}, slots={meta['used_slots']}/{meta['slot_count']}"
|
||||
)
|
||||
print(f"Mode : lod={args.lod}, group={args.group}, wireframe={bool(args.wireframe)}")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Primitive NGI MSH renderer (software, dependency-free)."
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
list_models = sub.add_parser("list-models", help="List .msh entries in an NRes archive.")
|
||||
list_models.add_argument("--archive", required=True, help="Path to archive (e.g. animals.rlb).")
|
||||
list_models.set_defaults(func=cmd_list_models)
|
||||
|
||||
render = sub.add_parser("render", help="Render one model to PPM image.")
|
||||
render.add_argument("--archive", required=True, help="Path to NRes archive or direct model payload.")
|
||||
render.add_argument(
|
||||
"--model",
|
||||
help="Model entry name (*.msh) inside archive. If omitted, first .msh is used.",
|
||||
)
|
||||
render.add_argument("--output", required=True, help="Output .ppm file path.")
|
||||
render.add_argument("--lod", type=int, default=0, help="LOD index 0..2 (default: 0).")
|
||||
render.add_argument("--group", type=int, default=0, help="Group index 0..4 (default: 0).")
|
||||
render.add_argument("--max-faces", type=int, default=120000, help="Face limit (default: 120000).")
|
||||
render.add_argument("--width", type=int, default=1280, help="Image width (default: 1280).")
|
||||
render.add_argument("--height", type=int, default=720, help="Image height (default: 720).")
|
||||
render.add_argument("--yaw", type=float, default=35.0, help="Yaw angle in degrees (default: 35).")
|
||||
render.add_argument("--pitch", type=float, default=18.0, help="Pitch angle in degrees (default: 18).")
|
||||
render.add_argument("--wireframe", action="store_true", help="Draw white wireframe overlay.")
|
||||
render.set_defaults(func=cmd_render)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
return int(args.func(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user